From 2c4b9734368451991191fe3e7e4de7375df2addc Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Wed, 29 Apr 2026 00:41:30 -0400 Subject: [PATCH 01/98] 0.0.130-rc.1 --- CHANGELOG.md | 770 ++++++++++++++++++ Cargo.lock | 271 +++--- changes/releases/0.0.130-rc.1.md | 769 +++++++++++++++++ changes/unreleased/01-human-vs-ai-tracking.md | 43 - changes/unreleased/02-focus-code-review.md | 31 - changes/unreleased/03-flow-shield.md | 28 - changes/unreleased/04-adaptive-break-coach.md | 33 - changes/unreleased/05-struggle-ai-bridge.md | 31 - .../unreleased/06-flow-triggers-dashboard.md | 29 - changes/unreleased/07-focus-scored-commits.md | 38 - changes/unreleased/08-optimal-task-router.md | 29 - changes/unreleased/09-eeg-heatmap.md | 29 - changes/unreleased/10-daemon-event-storage.md | 16 - changes/unreleased/11-shared-daemon-client.md | 32 - changes/unreleased/feat-activity-dashboard.md | 19 - changes/unreleased/feat-brain-awareness.md | 12 - changes/unreleased/feat-brain-insights.md | 24 - changes/unreleased/feat-burn-mlx.md | 9 - .../unreleased/feat-conversations-search.md | 18 - changes/unreleased/feat-data-collection.md | 12 - changes/unreleased/feat-dependabot-fixes.md | 11 - changes/unreleased/feat-design-system.md | 19 - changes/unreleased/feat-dev-loops.md | 10 - .../unreleased/feat-exg-inference-backend.md | 9 - changes/unreleased/feat-fast-umap-1.6.md | 24 - changes/unreleased/feat-gpu-fft-1.2.md | 8 - changes/unreleased/feat-grayscale-dnd.md | 7 - .../feat-history-search-integration.md | 5 - changes/unreleased/feat-neuroskill-cli.md | 5 - changes/unreleased/feat-search-ui-redesign.md | 11 - changes/unreleased/feat-sidebar-cards.md | 30 - changes/unreleased/feat-terminal-tracking.md | 24 - changes/unreleased/feat-validation-daemon.md | 16 - .../unreleased/feat-validation-tauri-ui.md | 18 - changes/unreleased/feat-validation-tests.md | 11 - .../feat-validation-vscode-extension.md | 13 - .../unreleased/feat-vscode-extension-ci.md | 7 - changes/unreleased/feat-vscode-extension.md | 84 -- .../unreleased/feat-vscode-readme-rewrite.md | 5 - .../feat-vscode-readme-screenshots.md | 9 - .../feat-vscode-sidebar-light-mode.md | 7 - changes/unreleased/feat-widget-a11y-i18n.md | 3 - changes/unreleased/feat-widget-analysis.md | 3 - changes/unreleased/feat-widget-biometrics.md | 3 - .../unreleased/feat-widget-brain-dashboard.md | 3 - .../unreleased/feat-widget-calendar-mind.md | 3 - changes/unreleased/feat-widget-deep-links.md | 3 - changes/unreleased/feat-widget-dev-infra.md | 3 - .../feat-widget-focus-streak-session.md | 3 - changes/unreleased/feat-widget-interactive.md | 3 - .../unreleased/feat-widget-offline-cache.md | 3 - changes/unreleased/feat-widget-reload.md | 3 - changes/unreleased/feat-zuna-rs-mlx.md | 9 - .../fix-daemon-deadlocks-and-correctness.md | 9 - changes/unreleased/fix-daemon-performance.md | 8 - changes/unreleased/fix-daemon-reliability.md | 9 - changes/unreleased/fix-eeg-pipeline.md | 5 - changes/unreleased/fix-frontend-leaks.md | 9 - .../fix-macos-libusb-static-link.md | 3 - changes/unreleased/fix-security.md | 5 - changes/unreleased/fix-timezone.md | 6 - crates/skill-iroh/Cargo.toml | 3 + package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 65 files changed, 1693 insertions(+), 988 deletions(-) create mode 100644 changes/releases/0.0.130-rc.1.md delete mode 100644 changes/unreleased/01-human-vs-ai-tracking.md delete mode 100644 changes/unreleased/02-focus-code-review.md delete mode 100644 changes/unreleased/03-flow-shield.md delete mode 100644 changes/unreleased/04-adaptive-break-coach.md delete mode 100644 changes/unreleased/05-struggle-ai-bridge.md delete mode 100644 changes/unreleased/06-flow-triggers-dashboard.md delete mode 100644 changes/unreleased/07-focus-scored-commits.md delete mode 100644 changes/unreleased/08-optimal-task-router.md delete mode 100644 changes/unreleased/09-eeg-heatmap.md delete mode 100644 changes/unreleased/10-daemon-event-storage.md delete mode 100644 changes/unreleased/11-shared-daemon-client.md delete mode 100644 changes/unreleased/feat-activity-dashboard.md delete mode 100644 changes/unreleased/feat-brain-awareness.md delete mode 100644 changes/unreleased/feat-brain-insights.md delete mode 100644 changes/unreleased/feat-burn-mlx.md delete mode 100644 changes/unreleased/feat-conversations-search.md delete mode 100644 changes/unreleased/feat-data-collection.md delete mode 100644 changes/unreleased/feat-dependabot-fixes.md delete mode 100644 changes/unreleased/feat-design-system.md delete mode 100644 changes/unreleased/feat-dev-loops.md delete mode 100644 changes/unreleased/feat-exg-inference-backend.md delete mode 100644 changes/unreleased/feat-fast-umap-1.6.md delete mode 100644 changes/unreleased/feat-gpu-fft-1.2.md delete mode 100644 changes/unreleased/feat-grayscale-dnd.md delete mode 100644 changes/unreleased/feat-history-search-integration.md delete mode 100644 changes/unreleased/feat-neuroskill-cli.md delete mode 100644 changes/unreleased/feat-search-ui-redesign.md delete mode 100644 changes/unreleased/feat-sidebar-cards.md delete mode 100644 changes/unreleased/feat-terminal-tracking.md delete mode 100644 changes/unreleased/feat-validation-daemon.md delete mode 100644 changes/unreleased/feat-validation-tauri-ui.md delete mode 100644 changes/unreleased/feat-validation-tests.md delete mode 100644 changes/unreleased/feat-validation-vscode-extension.md delete mode 100644 changes/unreleased/feat-vscode-extension-ci.md delete mode 100644 changes/unreleased/feat-vscode-extension.md delete mode 100644 changes/unreleased/feat-vscode-readme-rewrite.md delete mode 100644 changes/unreleased/feat-vscode-readme-screenshots.md delete mode 100644 changes/unreleased/feat-vscode-sidebar-light-mode.md delete mode 100644 changes/unreleased/feat-widget-a11y-i18n.md delete mode 100644 changes/unreleased/feat-widget-analysis.md delete mode 100644 changes/unreleased/feat-widget-biometrics.md delete mode 100644 changes/unreleased/feat-widget-brain-dashboard.md delete mode 100644 changes/unreleased/feat-widget-calendar-mind.md delete mode 100644 changes/unreleased/feat-widget-deep-links.md delete mode 100644 changes/unreleased/feat-widget-dev-infra.md delete mode 100644 changes/unreleased/feat-widget-focus-streak-session.md delete mode 100644 changes/unreleased/feat-widget-interactive.md delete mode 100644 changes/unreleased/feat-widget-offline-cache.md delete mode 100644 changes/unreleased/feat-widget-reload.md delete mode 100644 changes/unreleased/feat-zuna-rs-mlx.md delete mode 100644 changes/unreleased/fix-daemon-deadlocks-and-correctness.md delete mode 100644 changes/unreleased/fix-daemon-performance.md delete mode 100644 changes/unreleased/fix-daemon-reliability.md delete mode 100644 changes/unreleased/fix-eeg-pipeline.md delete mode 100644 changes/unreleased/fix-frontend-leaks.md delete mode 100644 changes/unreleased/fix-macos-libusb-static-link.md delete mode 100644 changes/unreleased/fix-security.md delete mode 100644 changes/unreleased/fix-timezone.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6918cd4c..aa6a44c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4626,3 +4626,773 @@ Past releases are archived in [`changes/releases/`](changes/releases/). - Better updater configuration --- + +## [0.0.130-rc.1] — 2026-04-29 + +### Features + +- **Human vs AI activity tracking**: every edit and commit is now classified as `source: "human"` or `source: "ai"` in real-time. `AIActivityTracker` watches VSCode commands (Copilot/Codeium completions, inline chat, AI-generated commit messages) to tag subsequent edits/commits, exposes `getAIRatioForFile()` (rolling 5-minute ratio) for CodeLens + sidebar, and forwards the `source` field to the daemon for `build_events` / `ai_events` storage. + +## How it works + +The `AIActivityTracker` monitors VSCode command execution to detect AI tool usage: + +- **Inline completions** (Copilot, Codeium) — edits within 5 seconds after an AI command are tagged `source: "ai"` +- **Inline chat** (`inlineChat.start`, `copilot.interactiveEditor.*`) — subsequent edits in the same file are tagged AI +- **Commit messages** — `github.copilot.git.generateCommitMessage` marks the next commit as AI-assisted +- **Everything else** — classified as `source: "human"` + +## What's tracked + +| Signal | Classification | +|--------|---------------| +| Manual typing | `human` | +| Copilot inline suggestion accepted | `ai` | +| Copilot inline chat edits | `ai` | +| Paste from external source | `human` | +| AI-generated commit message | `ai` | +| Manually typed commit message | `human` | + +## Per-file AI ratio + +`AIActivityTracker.getAIRatioForFile(path)` returns a rolling 5-minute ratio (0.0 = all human, 1.0 = all AI) used by: +- CodeLens annotations (shows "AI-Assisted" vs focus score) +- Sidebar (Human/AI percentage display) +- Brain status command (Human/AI split) + +## Daemon integration + +The `source` field is sent on every `edit` and `git_commit` event to the daemon. The daemon stores: +- AI commits as `"git commit (ai-assisted)"` in `build_events` +- AI commits also as `ai_events` for analytics weighting +- Completion acceptances as `ai_events` with type `"suggestion_accepted"` + +## Files + +- `src/ai-tracker.ts` — Core tracker (new) +- `src/events.ts` — Wired to classify edits and commits +- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Daemon-side storage + +- **Focus-aware code review CodeLens**: annotations at the top of each file show the developer's focus level when the file was last edited (`⚠ Low Focus (42)`, `ℹ Focus: 65/100`, `🤖 AI-Assisted (85%)`, or none for high focus). `NeuroSkill: Show Files Needing Review` command lists low-focus human-authored files. Toggle via `neuroskill.focusCodeLens`. + +## What you see + +- `⚠ Low Focus (42) — Review Recommended` — File was edited during low focus. Click to see all files needing review. +- `ℹ Focus: 65/100` — Moderate focus, informational only. +- `🤖 AI-Assisted (85%)` — Most edits were AI-generated, focus score not applicable. +- No annotation — High focus (>70) or no data yet. + +## Commands + +**NeuroSkill: Show Files Needing Review** (`Cmd+Shift+P`) +- Shows a QuickPick list of files edited during low focus (<50) that were mostly human-authored +- Sorted by focus score (lowest first) +- Select a file to open it + +## How it works + +- `FocusCodeLensProvider` queries `/brain/cognitive-load` (grouped by file) every 30 seconds +- Combines focus data with `AIActivityTracker.getAIRatioForFile()` to distinguish human vs AI code +- Files with high AI ratio (>70%) show AI label instead of focus score — AI code doesn't reflect human cognitive state + +## Settings + +`neuroskill.focusCodeLens` (default: `true`) — Toggle CodeLens annotations on/off. + +## Files + +- `src/codelens-provider.ts` — CodeLens provider (new) + +- **Smart Interruption Shield**: when `/brain/flow-state` reports `in_flow: true`, VS Code's Do Not Disturb mode auto-enables and the status bar shows `$(shield) In Flow 12m`. `NeuroSkill: Toggle Flow Shield` cycles Auto / Forced-on / Forced-off. Toggle via `neuroskill.flowShield`. + +## How it works + +- When `/brain/flow-state` reports `in_flow: true`, the Flow Shield activates +- Enables VSCode's Do Not Disturb mode (VSCode 1.90+) +- Shows `$(shield) In Flow 12m` in the status bar with elapsed time +- When flow state ends, DND is automatically disabled + +## Manual override + +**NeuroSkill: Toggle Flow Shield** (`Cmd+Shift+P`) + +Cycles through three modes: +1. **Auto** (default) — activates/deactivates based on EEG flow detection +2. **Forced on** — always active regardless of flow state +3. **Forced off** — never active + +## Settings + +`neuroskill.flowShield` (default: `true`) — Enable/disable the flow shield feature. + +## Files + +- `src/flow-shield.ts` — Flow shield implementation (new) +- `src/brain.ts` — Calls `flowShield.update()` every 30s + +- **Adaptive Break Coach**: personalized break timing based on the developer's actual EEG focus cycle (via `/brain/break-timing`), not generic Pomodoro. Status bar countdown `$(clock) Break in 8m`, max one notification per cycle, auto-resets when fatigue indicates idleness. `NeuroSkill: Take a Break` resets the timer manually. Toggle via `neuroskill.breakCoach`. + +## How it works + +- Queries `/brain/break-timing` to learn the developer's natural focus cycle length +- Shows a countdown in the status bar: `$(clock) Break in 8m` +- When the predicted focus drop is imminent (<5 min), the countdown turns visible +- When the cycle ends, shows `$(clock) Break time` and optionally notifies + +## Notifications + +- Max one notification per focus cycle +- Message: "You've been focused for 47m. Your natural cycle is 42m — take a break?" +- Buttons: "Take Break" (resets timer) or "Dismiss" + +## Timer sync + +The break coach automatically syncs with the daemon's fatigue data. If `continuous_work_mins` drops below 5 (indicating the user was idle), the session timer resets — no false break suggestions after returning from lunch. + +## Commands + +**NeuroSkill: Take a Break** (`Cmd+Shift+P`) — Manually acknowledge a break and reset the timer. + +## Settings + +`neuroskill.breakCoach` (default: `true`) — Enable/disable break coaching. + +## Files + +- `src/break-coach.ts` — Break coach implementation (new) +- `src/brain.ts` — Calls `breakCoach.refresh()` and `resetSessionIfIdle()` every 30s + +- **Struggle → AI assist bridge**: when `/brain/struggle-predict` flags a file (EEG focus + undo rate + velocity drop + time-on-file), shows an actionable notification with **Open Copilot Chat**, **Open Terminal**, and **Step Back** buttons. Debounced to one suggestion per file per 10 minutes. Toggle via `neuroskill.struggleBridge`. + +## How it works + +- Monitors `/brain/struggle-predict` (EEG focus + undo rate + velocity drop + time-on-file) +- When `struggling: true`, shows an actionable notification: + > "Stuck on auth.ts? (score: 78) Consider breaking the problem into smaller pieces." + +## Action buttons + +| Button | Action | +|--------|--------| +| **Open Copilot Chat** | Opens GitHub Copilot interactive chat (or generic chat panel) | +| **Open Terminal** | Toggles terminal for CLI debugging | +| **Step Back** | Dismiss and take a mental break | + +## Debouncing + +- Max one suggestion per file per 10 minutes +- Prevents notification fatigue while still catching genuine struggles + +## Settings + +`neuroskill.struggleBridge` (default: `true`) — Enable/disable struggle detection and AI suggestions. + +## Files + +- `src/struggle-bridge.ts` — Struggle bridge implementation (new) +- `src/brain.ts` — Calls `struggleBridge.check()` every 30s (replaces the old generic struggle notification) + +- **Personal Flow Triggers dashboard**: collapsible "Your Flow Recipe" sidebar section mining 7-day EEG + activity history — best language (`/brain/code-eeg`), peak hours (`/brain/optimal-hours`), natural cycle length (`/brain/break-timing`), top flow killer (`/brain/context-cost`). Toggle via `neuroskill.flowTriggers`. + +## What you see + +In the NeuroSkill sidebar panel, a collapsible "Your Flow Recipe" section shows: + +- **Best language** — "Focus best on Rust (82)" — from `/brain/code-eeg` +- **Peak hours** — "Peak hours: 9:00, 10:00, 14:00" — from `/brain/optimal-hours` +- **Natural cycle** — "Natural cycle: 42m" — from `/brain/break-timing` +- **Flow killer** — "Flow killer: Slack (focus 38 at switch)" — from `/brain/context-cost` + +## Data sources + +| Insight | API Endpoint | Time Range | +|---------|-------------|------------| +| Best languages | `/brain/code-eeg` | Last 7 days | +| Peak hours | `/brain/optimal-hours` | Last 7 days | +| Natural cycle | `/brain/break-timing` | Last 7 days | +| Flow killers | `/brain/context-cost` | Last 7 days | + +## Settings + +`neuroskill.flowTriggers` (default: `true`) — Show/hide the flow triggers section in the sidebar. + +## Files + +- `src/sidebar.ts` — `_fetchFlowTriggers()` and `_renderFlowTriggers()` methods + +- **Focus-scored git commits in sidebar**: collapsible "Recent Commits" section shows the last 8 commits with author marker (👤 human / 🤖 AI) and a color-coded focus badge captured at commit time via `/brain/flow-state`. AI-assisted commits show "AI" instead of a focus score. Toggle via `neuroskill.focusCommits`. + +## What you see + +In the NeuroSkill sidebar, a collapsible "Recent Commits" section shows the last 8 commits: + +``` +👤 82 fix: resolve auth race condition +👤 45 chore: update dependencies +🤖 AI refactor: extract helper functions +👤 71 feat: add user preferences +``` + +- **👤** = human-authored commit +- **🤖** = AI-assisted commit (message generated by Copilot) +- **Focus badge** = color-coded: green (>70), yellow (40-70), red (<40) +- AI commits show "AI" instead of a focus score — AI output doesn't measure human cognition + +## How it works + +- When the extension detects a git commit (SCM input box clears), it: + 1. Snapshots current EEG focus via `/brain/flow-state` + 2. Checks `AIActivityTracker.isCommitAIAssisted()` + 3. Records the commit with focus score + source label +- Commits stored in-memory (last 15), refreshed on sidebar render +- The daemon also stores commits with human/AI distinction in `build_events` + +## Settings + +`neuroskill.focusCommits` (default: `true`) — Show/hide the commits section in the sidebar. + +## Files + +- `src/sidebar.ts` — `recordCommit()`, `_renderCommits()` +- `src/extension.ts` — Wires commit detection to sidebar recording +- `src/events.ts` — `onCommit` callback with human/AI source + +- **Optimal Task Router**: monitors flow score every 30 s and, when it changes by >20 points, suggests an appropriate task type (refactoring/new features at high focus, code review at moderate, docs/routine at low). Debounced to one suggestion per 15 minutes. Toggle via `neuroskill.taskRouter`. + +## How it works + +- Monitors the flow state score every 30 seconds +- When focus changes by >20 points from the last reading, suggests an appropriate task type: + +| Focus Level | Suggestion | +|------------|------------| +| >75 | "Focus is high (85) — great time for complex work like refactoring or new features." | +| 45-75 | "Focus moderate (58) — good for code review, testing, or incremental tasks." | +| <45 | "Focus low (32) — consider documentation, routine tasks, or a break." | + +## Debouncing + +- Maximum one suggestion every 15 minutes +- No suggestion on the first reading (establishes baseline) +- No suggestion if focus stays within 20 points of the last reading + +## Settings + +`neuroskill.taskRouter` (default: `true`) — Enable/disable task routing suggestions. + +## Files + +- `src/task-router.ts` — Task router implementation (new) +- `src/brain.ts` — Calls `taskRouter.check()` every 30s + +- **Activity dashboard tab**: new Settings tab with daily summary, productivity score (0-100 composite), hourly heatmap, top files/projects, language breakdown, focus sessions, meetings, and stale file alerts. +- **Focus session replay**: click any session to expand a detailed view with file timeline, edit counts, EEG focus overlay bar, and meeting interruptions. +- **Weekly report with CSV export**: 7-day activity digest with daily breakdown chart and one-click CSV download. +- **Productivity scoring**: composite score from edit velocity + deep work time + context stability + EEG focus. +- **Stale file detection**: files edited but untouched for 7+ days. +- **Code-Brain correlation**: focus ranked by language, project, and individual files — shows which code drains or energizes you. +- **Activity timeline**: unified chronological feed of all events (files, builds, meetings, AI, clipboard) with EEG focus indicators. + +- **Brain awareness API**: 14 endpoints under `/v1/brain/*` — flow state, cognitive load, meeting recovery, optimal hours, fatigue check, undo struggle, daily brain report, break timing, deep work streak, task type detection, struggle prediction, interruption recovery, code-EEG correlation, and unified timeline. +- **Flow state detector**: real-time check if user is in deep focus (high focus + low switches + sustained editing). +- **Task type detection**: auto-classify coding/debugging/reviewing/refactoring/testing from activity patterns. +- **Struggle prediction**: fuse undo rate + velocity drop + focus decline to predict when user is stuck. Includes actionable suggestion. +- **Interruption recovery measurement**: measure actual focus recovery time per interruption source (Slack avg 12min, Zoom avg 23min, etc.). +- **Fatigue monitor**: 15-minute background check broadcasts `fatigue-alert` and sends OS notification when focus declines. +- **Daily brain report**: 6pm OS notification with morning/afternoon/evening brain summary. +- **Weekly digest notification**: Monday 9am OS notification with weekly summary. ISO week dedup prevents re-fire on daemon restart. +- **Break timing optimizer**: detect natural focus cycle length from 5-minute EEG buckets. +- **Deep work streak**: gamified consecutive days with 60+ minutes of deep work. + +- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable EEG-fused insights in one call. +- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — shows how focus level predicts test outcomes. +- **Hourly productivity**: churn volume + undo rate + avg EEG focus by hour of day — identifies peak and trough hours. +- **Context switch recovery**: EEG focus level at each editor/terminal/panel transition — measures the cognitive cost of switching. +- **AI tool impact on focus**: per-app (Claude, Pi) focus delta vs baseline — quantifies whether AI tools help or hurt flow state. +- **Focus by language**: avg EEG focus + undo rate per programming language — identifies which languages are cognitively easiest/hardest. +- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day — shows when edit-test loops are fastest. +- **Tool focus impact**: avg EEG focus per command category (docker, git, deploy, etc.) — reveals which tools correlate with lowest focus. +- **EEG timeseries table**: periodic JSON snapshots of all brain metrics (every 5s during recording). Extensible — new metrics without schema changes. Any event correlatable by timestamp join. +- **EEG timeseries worker**: background thread writes full band powers to `eeg_timeseries` every 5s when an EEG session is active. +- **`POST /v1/brain/eeg-at`**: get EEG metrics at a specific timestamp (nearest sample). +- **`POST /v1/brain/eeg-range`**: get EEG time-series in a range for charts and correlation analysis. +- **Window focus tracking**: `window_focus` events sent when VS Code gains/loses focus. EEG not attributed to coding when VS Code is in background. +- **Live EEG attachment**: `latest_bands` focus/mood injected at event storage time for terminal commands, zone switches, and conversations. + +- **`mlx-e2e` test suite**: new suite in `scripts/test-all.sh` running UMAP and FFT e2e tests with MLX features. Auto-skips on non-macOS. Available via `npm run test:mlx-e2e`. + +- **Conversations table**: stores all AI coding assistant messages (Claude, Pi) with app, role (user/assistant/tool), text, cwd, timestamp, session ID, EEG focus/mood. +- **FTS5 full-text search**: SQLite virtual table for instant keyword search across all conversation text. `POST /v1/brain/search-conversations {"query":"JWT","mode":"fts"}`. +- **Fuzzy search**: LIKE-based substring matching for partial queries. `{"query":"rate limit","mode":"fuzzy"}`. +- **Structured search**: filter by app, role, time range. `{"mode":"structured","app":"claude","role":"user","since":...}`. +- **Semantic search**: user prompts embedded via fastembed (nomic-embed-text-v1.5, local, no API credits) and stored in HNSW label index. Searchable by meaning, not just keywords. +- **Generic embedding store**: `embeddings` table decoupled from specific data tables. Multi-model support — can re-embed with different models, store multiple vectors per item. Source tracking (source_type, source_id). +- **Code context HNSW index**: separate `code_context_index.hnsw` file for code-specific semantic search, keeping EEG label searches uncontaminated. +- **Conversation events**: VS Code extension sends `conversation_message` events to daemon for each Claude/Pi message. User prompts get embedded; assistant responses and tool calls get FTS-indexed only (saves compute). +- **Session tracking**: messages grouped by JSONL filename (session ID). Timestamps from app's own data (ISO 8601 UTC), not extension read time. +- **Embedding settings**: `neuroskill.embedding.maxInputLength` (default 1000 chars) and `neuroskill.embedding.enableConversations` (default true) in VS Code settings. + +- **Meeting detection**: automatically detect Zoom, Teams, Slack, Google Meet, FaceTime, Discord, and Webex meetings from window titles. Track start/end times in `meeting_events` table. +- **Browser tab extraction**: extract page titles from Chrome, Safari, Firefox, Edge, Brave, Arc, Opera, Vivaldi, Chromium. Stored in `browser_title` column on `active_windows`. +- **Clipboard monitoring**: opt-in macOS clipboard change tracking (metadata only — content never stored). Records source app, content type, and size. Includes Automation permission check and settings UI. +- **Multi-monitor awareness**: track windows on secondary monitors across macOS (AppleScript), Linux (wmctrl), Windows (EnumWindows). Dynamic primary screen resolution detection. +- **Undo frequency tracking**: detect undo/redo from file edit diffs via reversal heuristic. `undo_estimate` per 5-second chunk, `undo_count` per file interaction. + +- **Design tokens** (`webview-ui/src/lib/tokens.css`): 30+ CSS custom properties organized into core palette (5 colors), surfaces (4 levels), backgrounds (14 translucent tints), borders (3 colors), semantic aliases (7), and typography (3). Zero inline `rgba()` in App.svelte. +- **Reusable Svelte components** (`webview-ui/src/lib/`): + - `Card` — wrapper with title, info button, info panel, header-right slot, variant borders (warn/danger/info) + - `MetricRow` — label + value pair with variant colors (warn/accent/dim/good/bad) + - `Chevron` — collapsible section with chevron toggle, count badge, slot content + - `ProgressBar` — horizontal bar with value/max, 5 color variants, optional label + - `Gauge` — circular SVG ring with animated fill, value, label + - `Badge` — text badge with 7 variants (default/good/warn/bad/blue/live/score-circle/si) + - `Callout` — alert box with 3 variants (warn/danger/info) +- **Component index** (`webview-ui/src/lib/index.ts`): barrel export for all components. +- **Timestamp compliance**: 25 automated tests (`src/test-timestamps.ts`) verifying: + - No `toLocaleTimeString`/`toLocaleDateString` in data layer (activity-tracker, events, brain, config, vt-parser) + - `toLocaleTimeString` used in UI layer (App.svelte) for display + - `Date.now()` returns UTC milliseconds + - ISO 8601 strings parsed to UTC millis + - No hardcoded timezone offsets in data layer + - All stored timestamps are UTC; local conversion only at UI boundary + +- **Dev loop detection**: identifies edit-build-test cycles from terminal command history. Groups consecutive runs of the same build/test command into loops. +- **`dev_loops` table**: loop type, command, iteration count, pass/fail counts, avg cycle time, fastest/slowest cycle, EEG focus start/end, focus trend (rising/falling/stable). +- **`POST /v1/brain/dev-loops`**: returns detected loops for a time window with all metrics. +- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages." +- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl → infrastructure (0.8), deploy → deploying (0.85), git dominating → git_management (0.7), test → testing (0.85), debug → debugging (0.9). +- **Dev loops in sidebar**: command, iteration count, pass rate, avg cycle time, focus trend arrow. Failing loops get red left border. +- **Dev loops in Tauri Activity tab**: expanded view with pass rate percentage, cycle time, focus trend. +- **Loop efficiency by hour**: insight showing build/test cycle time + pass rate by time of day. + +- **Inference backend selector**: `exg_inference_device` setting expanded from `gpu | cpu` to `auto | mlx | gpu | cpu`. Default changed from `gpu` to `auto` (picks MLX on macOS, GPU elsewhere). +- **Four-button selector in EXG Settings**: Auto, MLX, GPU, CPU — each with localized label and description. Reconnect headset hint shown after change. +- **`embed-exg-mlx` feature**: new daemon Cargo feature that enables `skill-router/mlx`, `skill-eeg/mlx`, and `embed-zuna-mlx` together. + +- **fast-umap 1.6.0**: updated from 1.5.1 with MLX, PCA, and nearest-neighbor descent support. +- **MLX UMAP backend**: Apple Silicon native UMAP projection via `burn-mlx`. Runtime dispatch between MLX and GPU (wgpu) based on user preference. Auto defaults to MLX on macOS, GPU elsewhere. +- **Precision selector**: F32 / F16 precision for the GPU (wgpu) backend. MLX is F32-only (fast-umap trait constraint). Exposed in UMAP settings as chip group. +- **Backend & precision UI**: new "Compute Backend" section in UMAP settings with Auto / MLX / GPU chips and F32 / F16 precision chips. Pipeline summary badge shows active backend and precision. + +- **UMAP e2e benchmarks**: `umap_e2e_small` (200 pts), `umap_e2e_medium` (1K pts), `umap_e2e_large` (5K pts) with synthetic 32-dim EEG embeddings. Reports backend, timing, throughput (pts/sec), and separation score. + +- **gpu-fft 1.2.0**: updated from 1.1.1 with MLX FFT backend. EEG signal filtering (overlap-save convolution) uses MLX when `skill-eeg/mlx` is enabled. ~3.7x faster than wgpu at N=65536. +- **`skill-eeg/mlx` feature**: new feature gate that enables `gpu-fft/mlx` alongside `gpu-fft/wgpu`. Wired into `embed-exg-mlx` daemon feature. + +- **FFT MLX e2e**: `fft_e2e_roundtrip_256`, `fft_e2e_batch_4ch`, `fft_e2e_psd_peak_detection`, `fft_e2e_large_batch` (32 channels x 1024 samples). Validates round-trip accuracy, PSD peak detection, and batch throughput. + +- **Grayscale display mode**: optional system-wide grayscale that activates/deactivates in sync with Do Not Disturb. Reduces visual distraction during deep work. macOS only, default off. + +- **File activity in history sessions**: expanded EEG sessions show files worked on during the session with focus indicators, edit counts (+/-), language, and meeting interruption badges. +- **File activity in interactive search**: `file_activity` nodes in the 3D search graph linked to EEG epochs and text labels, showing which files were being edited during matching brain states. +- **Meeting nodes in search graph**: detected meetings appear alongside EEG results in the interactive search as amber nodes with temporal proximity edges. + +- **Git context card**: current branch (monospace), dirty/staged file count, ahead/behind indicators. Uses VS Code git extension API. +- **Session timeline card**: horizontal bar chart showing activity events by hour of day. Color-coded by volume. +- **Workspace activity card**: per-file edits, lines added/removed, focus time. Grouped by workspace folder (project), sorted by activity. Active file indicator (blue dot). Top 10 files per project. +- **Environment card**: editor/terminal/panel time split (colored stacked bar), tab count, editor groups, terminal count. Per-terminal cards with shell type, CWD, PID, shell integration status. +- **Terminal I/O sections**: collapsible with chevrons, minimized by default. Input shows timestamped commands with CWD. Output shows VT-parsed session content. +- **Terminal input card**: keystroke intensity per program (keys/min), duration, collapsible behind chevron. +- **Terminal impact card**: EEG focus delta by command category with pass rate percentage. +- **Context switch cost card**: focus level at each zone transition type with switch count. +- **Dev loops card**: edit-build-test cycles with iteration count, pass/fail rate, cycle time, focus trend (rising/falling/stable arrow). +- **Today's report card**: productivity score, morning/afternoon/evening period breakdown with focus bars and churn. +- **Energy card**: fatigue bar (inverse of focus decline), continuous work time, streak badge. +- **Struggle card**: EEG-only (hidden without recording), score 0-100, contributing factors. +- **Optimal hours card**: peak/avoid hours grid. +- **AI usage card**: acceptance rate bar, suggestion count, source breakdown. +- **Today vs yesterday card**: files and churn comparison with directional arrows. +- **Code review detection**: auto-detected when file switches > 5 and edit velocity < 1 line/min. +- **Process monitor card**: running dev servers (vite, next, webpack, cargo, node, python, docker, postgres) with ports and PIDs. +- **Info toggles**: every card has a `?` button explaining how metrics are calculated. +- **Dual-speed updates**: local data every 5s (visible), brain data every 30s. Pauses when sidebar hidden. + +- **OS-wide shell hooks**: preexec/precmd hooks for zsh, bash, fish, PowerShell. Background curl sends every command to daemon — zero delay to prompt. Self-contained scripts generated by daemon, stored in `~/.skill/shell-hooks/`. +- **Shell hook management**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check per shell. +- **Terminal command table**: `terminal_commands` with command text, binary name, args, cwd, exit code, duration, auto-categorized (284 CLI tools across 18 categories), EEG focus at start + end for delta calculation. +- **Binary extraction**: raw binary name stored separately from args. Lazy categorization — `POST /v1/brain/recategorize-commands` reruns rules on all historical data. `POST /v1/brain/binary-stats` discovers uncategorized tools by frequency. +- **284 CLI tools recognized**: git, gh, glab, hf, docker, kubectl, helm, aws, gcloud, az, brew, pip, cargo, npm, psql, redis-cli, ollama, claude, pi, vim, tmux, htop, terraform, and 260+ more across 18 categories (build, test, run, git, docker, deploy, install, navigate, debug, network, database, ai, editor, multiplexer, monitor, env, system, other). +- **Script session recording**: `script` PTY proxy wraps shell sessions, captures all terminal I/O (including input inside interactive programs). Logs stored in `~/.skill/terminal-logs/` with auto-rotation. +- **VT100 terminal emulator**: `vt-parser.ts` replays script recordings through a virtual terminal with scrollback capture. Handles cursor movement, screen clears, alternate screen buffer. Separates input from output via prompt pattern detection. +- **App-specific JSONL parsing**: reads Claude Code (`~/.claude/projects/`) and Pi agent (`~/.pi/agent/sessions/`) conversation files directly. Extracts user prompts with real timestamps, assistant responses, and tool calls. Supports multiple parallel sessions. +- **Readline history polling**: monitors `~/.python_history`, `~/.node_repl_history`, `~/.irb_history`, `~/.psql_history` for REPL input. +- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). +- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons with health indicators (green/yellow/red dot), recent commands preview, "How it works" documentation. + +- **Validation / fatigue-research daemon backend**: foundations for calibrating the Break Coach and Focus Score against four external instruments — KSS (Karolinska Sleepiness Scale), NASA-TLX (workload), PVT (Psychomotor Vigilance Task), and an EEG-derived fatigue index per Jap et al. 2009. +- **`skill-data::validation_store`** — new SQLite store at `~/.skill/validation.sqlite` with five tables (`config`, `kss_responses`, `tlx_responses`, `pvt_runs`, `prompt_log`). Persistent config is a single-row JSON blob with serde defaults so adding a new channel later is a non-migration. New constant `VALIDATION_FILE` in `skill-constants`. +- **EEG fatigue index**: pure function `eeg_fatigue_index(bands) → Option` computing `(α + θ) / β` over the existing band-power snapshot. Guards against missing bands and zero-β. Passive; ships **on** because it costs nothing when no headset is attached. +- **Pure scheduler `decide_prompt(ctx, ...) → PromptDecision`**: respects the `respect_flow` master gate, configurable quiet hours, per-channel daily caps, runtime snoozes, and rate limits. Channel ordering: KSS first (lightest), TLX after a long task unit, PVT on a weekly cadence. Live `read_in_flow` and `read_break_coach_active` open the activity store read-only and ask `flow_state_now(300).in_flow` and `fatigue_check().fatigued` so the gates actually mean something. +- **Sane defaults — opt-in everywhere**: KSS, TLX, PVT all ship `enabled = false`. Only the passive EEG fatigue index ships on. The `respect_flow` master gate ships on. + +- **Validation & Research settings tab** (`src/lib/settings/ValidationTab.svelte`): new top-level settings tab gathering five `SettingsCard` blocks — global gates (`respect_flow`, quiet hours), KSS, NASA-TLX, PVT, and EEG fatigue index — plus a Calibration Week button and a recent-results card. + - Every toggle / numeric input PATCHes one nested field via `daemonPatch("/v1/validation/config", …)`; the daemon's recursive JSON merge keeps the rest of the config untouched. + - EEG fatigue card shows the live `(α + θ) / β` value polled every 5 s from `/v1/validation/fatigue-index`. + - Calibration Week: single button that batch-updates all four channels to higher-frequency presets (KSS 8/day, TLX after every flow block ≥ 20 min, PVT mid-week) in one PATCH. +- **PVT panel** (`src/lib/settings/PvtPanel.svelte`): full 3-minute Psychomotor Vigilance Task. Random ITIs (2–10 s), green-dot stimulus, `performance.now()` for sub-millisecond RT measurement, tracks responses + false starts. On finish computes mean RT, median RT, slowest-10% mean RT (anticipation-resistant), and lapse count (RT > 500 ms per Dinges & Powell 1985), POSTs to `/v1/validation/pvt`. State machine: intro → running → done. +- **NASA-TLX form** (`src/lib/settings/TlxForm.svelte`): six-slider modal for the raw (un-weighted) NASA-TLX. Performance scale uses inverted endpoints ("Failure" → "Perfect") per Hart 2006. POSTs to `/v1/validation/tlx` with `task_kind`, `task_duration_secs`, optional `prompt_id` echo-back. +- **Pure stats helper** (`src/lib/settings/pvt-stats.ts`): extracted `mean`, `median`, `slowest10Mean`, `lapseCount`, `computeStats` from the PVT panel so the math is unit-testable without a Svelte renderer. +- **`daemonPatch(path, body)`** in `src/lib/daemon/http.ts` — needed for the validation config endpoint. + +- **Test coverage for the validation feature across all three layers** — 72 tests total, all passing. + - **Rust unit tests** (`crates/skill-data/src/validation_store.rs`): 19 tests covering config persistence, store round-trips, the `(α + θ) / β` fatigue index (Jap et al. 2009) including zero-β and missing-band edge cases, every scheduler branch (flow respect, quiet hours, snooze blocking, KSS rate limit, TLX trigger after long task, TLX skip on short task, PVT weekly cadence, PVT silence after recent run, quiet-window-equals-end semantics), and prompt-log queries. + - **Rust HTTP integration tests** (`crates/skill-daemon/src/routes/validation.rs`): 10 tests using `tower::ServiceExt::oneshot()` against a tempdir-backed `AppState` — `GET /config` defaults, `PATCH /config` partial-update + round-trip, `POST /kss` happy path, KSS score-out-of-range → 400, TLX subscale-out-of-range → 400, `GET /should-prompt` returns `{kind: "none"}` with default config, `POST /snooze` envelope, plus three `merge_json` tests covering deep nesting. + - **Vitest — PVT statistics** (`src/tests/pvt-stats.test.ts`, 12 tests): edge cases for `mean`, `median`, `slowest10Mean` (including the n<10 fallback), `lapseCount` with the 500 ms Dinges & Powell threshold and explicit-threshold override, plus a known-fixture `computeStats` round-trip. + - **Vitest — i18n coverage** (`src/tests/validation-i18n.test.ts`, 23 tests): every VS Code l10n bundle exists and contains every user-facing key (KSS prompt + scores 1/5/9, TLX/PVT prompts, all four escape-hatch labels); every Tauri language `validation.ts` imports cleanly; English Tauri bundle covers every required key; every non-English Tauri bundle has at least the tab name and disclaimer translated. + +- **VS Code extension joins the validation prompt loop**: new `ValidationManager` (`extensions/vscode/src/validation.ts`) polls `/v1/validation/should-prompt` every 90 s and renders whichever channel the daemon decides to fire. + - **KSS** (1–9 sleepiness) — full QuickPick with Karolinska wording, plus Snooze 30m / Don't ask today / Stop these prompts escape hatches in-line. POSTs the answer to `/v1/validation/kss` echoing the daemon's `prompt_id` so the prompt log can mark it answered. + - **NASA-TLX** — fallback path: shows an information message offering to open the form in the Tauri app (deep link `neuroskill://validation/tlx`) plus the same escape hatches. + - **PVT** — weekly nudge: offers to deep-link into the Tauri PVT panel (`neuroskill://validation/pvt`) or skip a week (snoozes for 6 days so the next reminder fires on cadence). +- **Two new commands**: `NeuroSkill: Open Validation Settings…` (deep links into the Tauri preferences pane) and `NeuroSkill: Check for Validation Prompt Now` (forces a single scheduler poll — useful for opt-in onboarding). +- **`DaemonClient.patch()`**: extension's daemon client gained a PATCH method so the "Stop these prompts" escape hatch can flip `enabled = false` on the persistent config. + +- **VS Code extension**: separate repo (`NeuroSkill-com/vscode-neuroskill`) as git submodule at `extensions/vscode/`. 50+ tracked event types across editing, navigation, debugging, git, AI, terminal, clipboard, and more. +- **Sidebar webview (Svelte 5)**: full brain dashboard in the VS Code activity bar with circular flow gauge, metrics strip, daily report, energy, struggle, optimal hours, workspace activity, environment, terminal impact, context cost, and dev loops. +- **Activity bar icon**: neural network SVG icon in the VS Code sidebar; NeuroSkill logo (PNG) in the webview header. +- **Brain status bar in VS Code**: polls daemon every 30s showing flow state, fatigue, streak, task type, and struggle score. Shows "offline" when daemon unreachable. Notifications for fatigue and struggle. +- **Dual-speed sidebar updates**: local data (files, terminals, layout) refreshes every 5s when sidebar is visible; brain endpoints every 30s. Pauses when hidden. +- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk via `globalStorageUri`. Auto-flushes on reconnect. Survives VS Code restarts. Cache count shown in status bar tooltip. +- **Workspace activity tracking**: per-file edits, lines added/removed, focus time, active file indicator. Grouped by workspace folder (project), sorted by activity. Top 10 files per project. +- **Terminal command tracking**: full command text, exit code, cwd, output streaming (via `execution.read()`), shell type detection (zsh/bash/fish/powershell/cmd/node/python), focus time per terminal, PID. Expandable command output in sidebar. +- **Terminal shell integration**: captures commands via `onDidStartTerminalShellExecution` / `onDidEndTerminalShellExecution` (VS Code 1.93+). CWD tracked via `onDidChangeTerminalShellIntegration`. +- **Zone tracking**: editor/terminal/panel time split with stacked color bar (blue/green/yellow). Layout chips showing tab count, editor groups, terminal count. +- **Auto EEG labeling**: every significant VS Code event auto-inserts a searchable label into EEG recordings with smart categorization (editing, debugging, git commits, AI assistance, meetings, errors, navigation, terminal commands, zone switches). +- **Inline label embedding**: auto-labels embedded immediately via fastembed for instant searchability (no idle reembed wait). +- **Command execution tracking**: 40+ VS Code commands tracked (go-to-definition, rename, find, format, fold, git, AI, debug, layout) with semantic categorization. +- **IntelliSense acceptance detection**: multi-char single-line insertions heuristically identified as autocomplete acceptances. +- **Clipboard tracking**: clipboard content changes polled every 5s with debounce. +- **Layout snapshots**: periodic (60s) capture of editor groups, visible editors, open tabs, terminal count sent to daemon. +- **Zone switch events**: editor/terminal/panel transitions sent to daemon with EEG focus snapshot. +- **File system watcher**: selective watching of package.json, Cargo.toml, go.mod, .git/HEAD for external change detection. +- **Environment context**: one-time capture of appHost, remoteName, shell, uiKind, language. +- **Info toggles**: every sidebar card has a `?` button explaining how metrics are calculated (flow score formula, struggle signals, energy bar, optimal hours, dev loops, terminal impact, context cost). +- **Open NeuroSkill button**: launches native app from sidebar (cross-platform: `open -a` macOS, `start` Windows, `xdg-open` Linux). Also in command palette. +- **Command palette → sidebar**: `Show Brain Status`, `Today's Report`, `Am I Stuck?`, `Best Time to Code` now open sidebar and scroll to relevant section instead of showing toast notifications. + +- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable insights in one call. +- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — "your tests fail 45% more when focus is low." +- **Hourly productivity**: churn + undo rate + avg focus by hour of day — "you write 3x more bugs after 2pm." +- **Context switch recovery**: focus level at editor/terminal/panel transitions — "switching to terminal costs 4 focus points." +- **AI tool impact**: Claude/Pi focus delta vs baseline — "Claude conversations drop focus by 8 points." +- **Focus by language**: avg EEG focus + undo rate per programming language — "best focus in Rust, worst in CSS." +- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day. +- **Tool focus impact**: avg focus when using each command category (docker, git, deploy, etc.). + +- **Widget accessibility and localization**. + +- **Analysis widgets (Optimal Hours, Daily Report, Weekly Trend)**. + +- **Biometric widgets (Heart Rate, Cognitive Load, EEG Band Power, Sleep)**. + +- **Brain Dashboard widget (medium)**. + +- **Calendar Mind State widget (large)**. + +- **Widget deep links (neuroskill:// URL scheme)**. + +- **Widget development infrastructure**. + +- **Core desktop widgets (Focus, Streak, Session, Break Timer)**. + +- **Interactive widget buttons (macOS 14+)**. + +- **Widget offline data caching**. + +- **Widget timeline reload on state changes**. + +- **zuna-rs 0.1.4**: updated from 0.1.3 with native MLX backend for EEG embedding inference. +- **`embed-zuna-mlx` feature**: new `ZunaMlxState` struct with `ZunaEncoder`. Adds `load_zuna_mlx()` and `encode_zuna_mlx()` functions matching the existing GPU/CPU variants. +- **Load priority**: when user selects Auto or MLX, encoder loading tries MLX first, then GPU f16, then GPU f32, then CPU. + +### Performance + +- **MLX vs GPU benchmarks** (Mac mini, Apple Silicon): + +| Dataset | Points | GPU (wgpu) | MLX | Speedup | +|---|---|---|---|---| +| Small | 200 | 120.9 s | 2.3 s | **51x** | +| Medium | 1,000 | 136.6 s | 7.1 s | **19x** | +| Large | 5,000 | 152.8 s | 23.8 s | **6.4x** | + +- **`open_readonly` for read-only handlers**: added `ActivityStore::open_readonly()` that skips 15+ ALTER TABLE migrations and opens in read-only mode. Switched 38+ HTTP handlers and 3 background tasks from `open` to `open_readonly`. +- **`PRAGMA busy_timeout=5000`**: added to `init_wal_pragmas` and `util::open_readonly` so all database connections (activity, labels, screenshots, EEG embeddings) wait up to 5 s on lock contention instead of failing immediately with SQLITE_BUSY. +- **Composite indexes**: added `(seen_at, file_path)` and `(seen_at, project)` indexes on `file_interactions` for queries that filter by time range and group by path or project. +- **Lock consolidation**: `productivity_score` reduced from 3 separate mutex acquisitions to 1 via internal `_q` query helpers. `weekly_digest` reduced from 12 to 4 locks. Introduced `daily_summary_q`, `context_switch_rate_q`, `get_focus_sessions_in_range_q` static methods that take `&Connection` directly. +- **`PRAGMA optimize` after pruning**: runs after hourly retention pruning to keep query planner statistics fresh. +- **Batch label lookup in interactive search**: replaced 15 per-epoch `get_labels_near` calls (each opening a new DB connection) with a single `get_labels_near_batch` call per text label, then in-memory filtering. Reduces search DB round-trips from ~28 to ~16. + +### Bugfixes + +- **Daemon-side event storage**: `git_commit`/`push`/`pull`/`checkout`/`stage`/`unstage`/`stash` events were received but never written to `build_events` (used only for EEG labels). Now persisted, with AI-assisted commits also recorded as `ai_events` (type `"ai_commit"`) and labelled `"git commit (AI)"`. +- **Completion-accepted events were dropped**: previously ignored by the daemon. Now stored as `ai_events` (type `"suggestion_accepted"`) and as edit chunks so they show up in code metrics and AI usage analytics. + +## Impact on analysis + +Brain analysis endpoints can now: +- Count human vs AI commits (`/brain/developer-insights`) +- Track AI suggestion acceptance rates (`/brain/ai-usage`) +- Include git activity in the activity timeline +- Weight human-authored code differently from AI output in focus/productivity scores + +## Files + +- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Event handler updates + +- **Schema migration**: ALTER TABLE for existing databases missing columns added across releases. Runs before DDL to handle all legacy schemas. +- **Retention pruning**: meetings, clipboard, and secondary_windows pruned alongside file interactions during hourly maintenance. + +- **Dismiss stale Dependabot alerts**: crossbeam-deque, crossbeam-utils, crossbeam-queue, memoffset, and glib alerts dismissed (already at patched versions or fixed in fork). + +- **CORS allow-methods now includes `PATCH`**: `crates/skill-daemon/src/main.rs` was advertising only `GET, POST, PUT, DELETE, OPTIONS`, so the browser preflight blocked `PATCH /v1/validation/config` from the Tauri webview. Added `Method::PATCH` to the list. + +- **Equal padding on screenshot edges**: viewport width was 360px while the body was 320px wide, leaving an asymmetric 40px right gutter. Matched viewport to body width so left and right gutters are now identical. + +- **Deadlock in daily-report endpoint**: `daily_brain_report` held the database mutex while calling `productivity_score`, which re-acquires the same mutex. Scoped the lock to drop before the nested call. +- **Deadlock in struggle-predict endpoint**: same pattern in `predict_struggle` — held mutex while calling `get_recent_files`. +- **`overall_focus` returned `-0.0`**: always wrapped in `Some` even with no EEG data. Now returns `null` when no focus samples exist. +- **`best_period` arbitrary without EEG**: picked the first SQL result when all `avg_focus` were `None`. Now returns empty string when no EEG data to compare. +- **`weekly_avg_deep_mins` returned `-0.0`**: divided by hardcoded 7 regardless of actual days. Now divides by actual day count, returns `0.0` when empty. +- **Notification text ugly without EEG**: daily report notification showed "Best: . Score: 25. Focus: 0." — now conditionally includes only available fields. +- **Brain endpoints return HTTP 200 on errors**: all 18 brain handlers silently returned `null` with 200 OK when the activity store was offline or a query failed. Refactored to use `run_query` helper that returns 503 (db_unavailable) or 500 (task_error) with structured `ApiError` JSON. + +- **Worker thread panic recovery with auto-restart**: all 4 activity worker threads (poller, input monitor, file watcher, clipboard monitor) now wrapped in `catch_unwind` via `spawn_resilient`. On panic, the worker logs the error and restarts after 5 s with freshly cloned `AppState` and `Arc`. +- **osascript timeout**: all `osascript` calls (active window poll, secondary windows, clipboard monitor) now use a 3-second timeout via `run_osascript` helper. Previously, a hung app could block the poller thread indefinitely, silently killing all activity tracking. +- **Daily report catch-up on late startup**: if the daemon starts after 18:00 local, the daily brain report fires on the first tick instead of waiting up to 1 hour for the hourly check. +- **WAL checkpoint on shutdown**: daemon now runs `PRAGMA optimize` on the activity database during graceful shutdown, ensuring the WAL is checkpointed and the database is clean for next start. +- **Missing WAL pragmas on EEG embedding store**: `day_store.rs` opened connections without `init_wal_pragmas`, missing both WAL mode and `busy_timeout`. +- **Retention pruning for new tables**: added `prune_terminal_commands`, `prune_ai_events`, `prune_zone_switches`, `prune_layout_snapshots` — wired into the hourly maintenance cycle so these tables don't grow unbounded. +- **TOCTOU race in calibration settings**: concurrent profile CRUD could lose writes. Added `modify_settings_blocking()` helper that runs under a global mutex on tokio's blocking thread pool — serializes read-modify-write without blocking the async runtime. + +- **Duration-averaged EEG**: `FileSnapshot` now samples EEG focus/mood every 5 s (at each edit-chunk tick) and writes the average to `file_interactions` at finalize, replacing the single snapshot captured at file-switch time. Falls back to the initial snapshot if no samples were collected (`COALESCE`). +- **EEG mood/focus sample count mismatch**: both `FileSnapshot` and `build_focus_sessions` used a single counter for focus and mood samples, inflating mood averages when they arrived independently. Split into separate `focus_count` and `mood_count`. +- **EEG ghost data after disconnect**: `latest_bands` was not cleared when an EEG device disconnected, causing stale focus/mood values to persist in reports and the activity pipeline. Now cleared on all 3 disconnect paths (idle timeout, stream end, explicit disconnect). + +- **InteractiveGraph3D event listener leaks**: `pointerdown` and `dblclick` listeners on the WebGL canvas were never removed in `onDestroy`. Extracted into named refs with cleanup. +- **UmapViewer3D event listener leaks**: 4 canvas event listeners (pointermove, pointerleave, pointerdown, pointerup) added as anonymous functions were never removed on unmount. Extracted into named module-level refs with removal in `onDestroy`. +- **ActivityTab brain polling leak**: `startBrainPolling()` was called on mount but `stopBrainPolling()` was never called on destroy, leaving a 30-second interval running after leaving the tab. +- **Brain polling stopped by peer component**: `stopBrainPolling()` unconditionally killed the interval even when other components still needed it. Added reference counting so polling only stops when the last consumer unmounts. +- **Memory leak in terminal_command_end labeling**: `Box::leak` was used to format non-zero exit codes in EEG auto-labeling, permanently leaking memory for every failed command. Replaced with owned `String`. +- **Screenshot encode failures untracked**: `encode_webp` failures (disk full, I/O error) silently continued without incrementing the `capture_errors` counter. Now counted so metrics reflect actual failures. +- **401 auto-retry**: daemon HTTP client now retries once with a fresh token on 401 (stale token after daemon restart) instead of failing immediately. + +- **Path traversal in `delete_session`**: `csv_path` parameter was passed unsanitized to `fs::remove_file`. Now validates the path is within `skill_dir` via canonicalize + starts_with. +- **Timing-vulnerable token comparison**: default bearer token checks in auth middleware used `==` (variable-time). Switched to `constant_time_eq` for both in-memory and on-disk token paths. +- **Unbounded CSV memory in EXG loader**: loading a CSV with millions of rows would allocate unlimited RAM. Added a 4M row cap (~4.3 hours at 256 Hz). + +- **Timezone-wrong period classification**: `daily_brain_report`, `hourly_edit_heatmap`, and `optimal_hours` used `seen_at % 86400` (UTC hour) instead of local hour. Daily report now uses `seen_at - day_start`; heatmap and optimal-hours accept a timezone offset computed server-side. +- **UTC midnight in all clients**: search page, CLI (`cmdActivity`, `cmdBrain`), VS Code extension, and Swift widget all computed `day_start` as UTC midnight instead of local midnight. Fixed to use `Date.setHours(0,0,0,0)` (JS/TS) and `Calendar.startOfDay` (Swift). +- **Widget empty daily-report body**: `DaemonClient.fetchDailyReport()` sent an empty POST body; the endpoint requires `dayStart`. Now sends local midnight. +- **Widget UTC sleep/calendar endpoints**: `fetchSleep()` and `fetchCalendarEvents()` used `% 86400` UTC approximations. Now use `Calendar.current` for correct local time boundaries. + +### Refactor + +- **Shared `DaemonClient` for VS Code extension**: extracted the repeated `fetch` + auth-token + port-discovery + 3 s timeout pattern into one class. All features now use `client.post(path, body)`. Returns `null` on failure (never throws), with `setToken()` for refresh on reconnect. + +## Before + +Every component (brain.ts, sidebar.ts, extension.ts) independently constructed fetch calls: +```typescript +const port = await discoverDaemonPort(config); +const base = `http://${config.daemonHost}:${port}/v1`; +const headers = { "Content-Type": "application/json" }; +if (token) headers["Authorization"] = `Bearer ${token}`; +const resp = await fetch(`${base}${path}`, { method: "POST", headers, body, signal: AbortSignal.timeout(3000) }); +``` + +## After + +```typescript +const client = new DaemonClient(config, token); +const result = await client.post("/brain/flow-state", { windowSecs: 300 }); +``` + +## Benefits + +- Single place to update auth, timeout, port discovery +- All 8 new features use the shared client +- `setToken()` method for token refresh on reconnect +- Returns `null` on any failure (never throws) — all features handle gracefully + +## Files + +- `src/daemon-client.ts` — DaemonClient class (new) + +- **Sidebar disclaimer test now accepts either localiser**: existing `vscode-sidebar-disclaimer.test.ts` updated so the `vscode.l10n.t("sidebar.disclaimer")` ↔ `tr("sidebar.disclaimer")` migration doesn't break the assertion. Both look up the same key. + +- **Conversations table + FTS5**: full-text search on all AI conversation messages (user/assistant/tool). Three search modes: FTS, fuzzy, structured. +- **EEG timeseries table**: periodic JSON snapshots of all brain metrics. Extensible — new metrics without schema changes. Join-at-query-time correlation with any event. +- **Embeddings store**: generic, multi-model. User prompts embedded via fastembed (local, no API credits). Can re-embed with different models. +- **Code context HNSW index**: separate from label index for code-specific semantic search. +- **Binary extraction**: terminal commands store raw binary name + args separately. Lazy categorization — 284 CLI tools recognized, rerun anytime. +- **EEG attachment**: live focus/mood from `latest_bands` injected at event storage time. +- **EEG timeseries worker**: writes full band powers to `eeg_timeseries` every 5s during recording. + +### Build + +- **CI for the VS Code extension** (`extensions/vscode/.github/workflows/`): two workflows live in the submodule repo (`vscode-neuroskill`). + - **`ci.yml`** — runs on every PR and `main` push. `npm ci` → `tsc` build → `vsce package` → uploads the `.vsix` as a 14-day artifact. No secrets, no publish. + - **`release.yml`** — fires two ways. *Tag push* (`git push --tags` after `npm version patch`) verifies the tag matches `package.json` and publishes. *Manual dispatch* with a `patch | minor | major | x.y.z` input bumps `package.json`, commits, tags, pushes, then publishes. Both paths run `npx @vscode/vsce publish` (uses `VSCE_PAT`) and `npx ovsx publish` (uses `OVSX_PAT`), then create a GitHub release with the `.vsix` attached and the latest `## ` block from `CHANGELOG.md` as release notes. +- **Skip-on-missing-secret**: if either `VSCE_PAT` or `OVSX_PAT` is unset, the corresponding publish step emits a `::warning::` and is skipped — letting you wire up Open VSX later without breaking the workflow. +- **Releasing section in the extension README**: documents the secret setup (Azure DevOps PAT, Open VSX token), the two release flows, and the one-time namespace claims (Marketplace publisher registration + `ovsx create-namespace`). + +- **macOS: link libusb statically into `skill-daemon`**: vendored libusb via `rusb`'s `vendored` feature in `crates/skill-devices`. The `antneuro` USB driver pulls in `rusb` → `libusb1-sys`, which by default uses `pkg-config` to find a system libusb-1.0.dylib. On the macOS-26 release runner that resolved to Homebrew's `/opt/homebrew/opt/libusb/lib/libusb-1.0.0.dylib`, so end users without Homebrew hit a launch-time `dyld: Library not loaded` error. Cargo feature unification now flips `libusb1-sys/vendored`, which compiles libusb from source and links `libusb-vendored.a` into the binary — verified by 105 `_libusb_*` text symbols present in the release binary and zero libusb references in `otool -L`. + +### CLI + +- **`neuroskill activity` expanded**: 19 subcommands — bands, window, input, windows, files, top-files, top-projects, languages, sessions, meetings, clipboard, builds, heatmap, switches, summary, score, digest, stale, timeline. +- **`neuroskill brain` added**: 14 subcommands — flow, load, load-files, recovery, optimal, fatigue, struggle, report, breaks, streak, task, stuck, interruptions, correlation. +- **`neuroskill vscode` added**: auto-build and install VS Code/VSCodium/Cursor extension from source on macOS, Linux, and Windows. + +### UI + +- **EEG focus timeline heatmap**: collapsible "Focus Timeline" sidebar section renders a ~280×36 px SVG sparkline of today's EEG focus (`/brain/eeg-range`, max 120 points), color-graded green/yellow/red, with hour labels and file-name annotations at peaks/valleys (merged from `/activity/timeline`). Toggle via `neuroskill.eegHeatmap`. + +## What you see + +In the NeuroSkill sidebar, a collapsible "Focus Timeline" section shows: + +- A ~280px wide, ~36px tall SVG sparkline +- Color gradient: green (>70 focus), yellow (40-70), red (<40) +- Hour labels along the bottom (0:00, 3:00, 6:00, ...) +- File names annotated at focus peaks and valleys + +## Data sources + +| Data | API Endpoint | +|------|-------------| +| EEG time-series | `/brain/eeg-range` (today, max 120 points) | +| File context | `/activity/timeline` (today, last 200 events) | + +The heatmap merges EEG data points with the closest timeline events to show which files correspond to focus peaks and dips. + +## Settings + +`neuroskill.eegHeatmap` (default: `true`) — Show/hide the heatmap in the sidebar. + +## Files + +- `src/sidebar.ts` — `_fetchHeatmap()` and `_renderHeatmap()` methods + +- **Brain status bar**: persistent bottom bar in Tauri app showing flow state, fatigue, streak, edit velocity, and focus score. +- **Brain dashboard card**: flow/fatigue/streak + focus progress bar on the EEG dashboard alongside existing BrainStateScores. +- **Brain indicators in ActivityTab**: flow state card (green), fatigue alert card (amber), and streak card (violet) at top. + +- **Tauri Brain Insights section** in Activity tab: test failure rates by focus level, focus by language bars, AI impact delta, tool focus grid, hourly productivity chart. +- **Tauri EEG focus inline**: terminal commands and conversation messages show EEG focus score (green/yellow/red) at the moment they occurred. +- **VS Code Brain Insights card**: test failure by focus chips, focus by language bars, tool impact chips. Only shown when EEG data is available. +- **VS Code Struggle card**: hidden when no EEG recording (focus is EEG-only, not activity-based). +- **VS Code Flow gauge label**: shows "focus" with EEG, "activity" without — no false claim of brain measurement. + +- **Tauri AI Conversations section** in Activity tab: timestamped message thread with role icons (user/assistant/tool), app name, EEG focus score, scrollable. +- **VS Code Conversations card**: timestamped messages with role icons, app badge, collapsible. +- **VS Code AI Usage card**: Copilot/Codeium acceptance rate bar, suggestion count, source breakdown chips. + +- **Search mode dropdown**: replaced 4-tab bar with a styled dropdown supporting 7 search modes — Interactive, EEG, Text, Images, Code, Meetings, Brain. +- **Diamond EEG nodes**: EEG epochs rendered as faceted octahedrons (diamonds) in the 3D search graph. Meeting nodes rendered as tetrahedrons (pyramids). Visual distinction by data type. +- **New search modes**: Code (file activity + brain state), Meetings (meeting events + recovery), Brain (flow/fatigue/struggle insights). +- **Terminal commands in Cmd-K**: added 4 terminal commands to the command palette — Recent Terminal Commands, Terminal Focus Impact, Context Switch Cost, Dev Loops. Translated labels in all 9 locales. + +- **Configurable alerts**: `neuroskill.alerts.focusLow` (warn when EEG focus drops below threshold), `neuroskill.alerts.continuousWorkMins` (warn after N minutes continuous work). +- **Daily digest notification**: at configurable time (default 17:30), shows productivity score + stats. "Full Report" button opens detail panel. +- **Full report panel**: `Cmd+Shift+R` opens full-width webview with same data as sidebar. +- **Keyboard shortcuts**: `Cmd+Shift+N` (toggle sidebar), `Cmd+Shift+R` (full report), `Cmd+Shift+Alt+N` (reconnect). +- **Open NeuroSkill button**: launches native app (cross-platform). +- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk. Auto-flushes on reconnect. + +- **Spacing pass on the validation tab and modals**: every card uses `flex flex-col gap-5 p-6` instead of the old `space-y-4` (which provided no padding override). Conditional sub-settings under a channel toggle are now indented under a left border (`ml-1 border-l-2 border-border pl-5`) so the parent/child relationship reads visually. Modals padded to `p-8` with `gap-3` button rows. TLX sliders gained `mt-1` lift off the description and `uppercase tracking-wide` low/high legend labels. + +- **Design tokens** (`tokens.css`): 30+ CSS custom properties — palette, surfaces, backgrounds, borders, semantic, typography. Zero inline rgba() in sidebar. +- **7 reusable Svelte components**: Card, MetricRow, Chevron, ProgressBar, Gauge, Badge, Callout. +- **Timestamp compliance**: 25 tests verifying UTC in data layer, local conversion only in UI. + +- **Theme-aware sidebar screenshots in the README**: every screenshot now ships in two variants (`*-dark.png`, `*-light.png`) and is embedded via a `` element with `prefers-color-scheme` sources. GitHub serves the variant matching the reader's OS theme; the marketplace gets the dark fallback. Six states captured: in-flow, stuck, fatigued, low-focus / off-peak, daemon-disconnected, status-bar strip. +- **Sidebar mock library + Playwright generator**: HTML stubs in `extensions/vscode/media/preview/` render the sidebar webview offline (no daemon required). A shared `_styles.css` drives both themes via a `:root.light` override; `npm run screenshots` (Playwright) toggles `` between captures and writes 12 PNGs to `media/screenshots/`. +- **Marketplace README pipeline**: `scripts/build-marketplace-readme.mjs` rewrites every relative `media/...` path (including ``, which `vsce` doesn't touch) to absolute GitHub raw URLs at package time. The source `README.md` keeps relative paths for GitHub + offline viewing; the generated `.marketplace.readme.md` is what `vsce package --readme-path` ships. + +- **Sidebar webview now renders correctly in light themes** (`extensions/vscode/src/sidebar.ts`). Three theme-fragile spots fixed: + - `.metric-card` background switched from `--vscode-sideBar-background` (which is the same colour as the surrounding sidebar — cards collapsed into a faint border in light mode) to `--vscode-editorWidget-background`, with `--vscode-input-background` and a translucent grey as cascading fallbacks. + - `.ai-bar` track switched from `--vscode-panel-border` to `--vscode-progressBar-background` so the empty bar is visible against a white background. + - Row-divider opacity bumped from 0.08 to 0.18; added `:last-child { border-bottom: none }` on commit and AI-metric rows so the last row doesn't double up against the disclaimer footer. +- **State colours stay hard-coded** — red ring for stuck, green for flow, amber for warning. They're semantic, not chrome, and they read fine on both themes. + +### Server + +- **`POST /v1/activity/shell-command`**: receives commands from shell hooks with command text, cwd, shell type, exit code. +- **`POST /v1/brain/terminal-commands`**: query recent commands with exit codes, durations, categories, EEG correlation. +- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category. +- **`POST /v1/brain/binary-stats`**: usage frequency per binary for discovering uncategorized tools. +- **`POST /v1/brain/recategorize-commands`**: rerun categorization rules on all historical commands. +- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end. +- **Zone switch events**: editor/terminal/panel transitions stored with EEG focus snapshot. `POST /v1/brain/context-cost` returns focus at each transition type. +- **Layout snapshots**: periodic (60s) tab/group/terminal counts from VS Code. + +- **`/v1/validation/*` HTTP endpoints** (axum): `GET /config`, `PATCH /config` (recursive JSON merge for partial updates), `POST /snooze`, `POST /disable-today`, `GET /should-prompt` (returns the daemon's prompt decision; logs the fire so the rate-limiter sees it), `POST /kss`, `POST /tlx`, `POST /pvt`, `POST /close-prompt`, `GET /results`, `GET /fatigue-index` (live `(α+θ)/β` from the latest band snapshot). +- **`AppState.validation_runtime`** (`crates/skill-daemon-state/src/state.rs`): new `Arc>` field holding ephemeral snooze/disable-today state. Resets on daemon restart by design — a crash loop shouldn't keep prompts suppressed forever. + +- **`terminal_commands` table**: shell command text, cwd, exit code, duration, auto-categorized (50+ patterns: build/test/run/git/docker/deploy/install/navigate/debug/network/other), EEG focus at start and end for delta calculation. +- **`dev_loops` table**: edit→build/test cycle tracking with iteration count, pass/fail rate, avg cycle time, focus trend (rising/falling/stable). +- **`zone_switches` table**: editor/terminal/panel transitions with EEG focus snapshot at moment of switch. +- **`layout_snapshots` table**: periodic tab/group/terminal counts from VS Code. +- **Event handler**: new match arms for `terminal_command_start`, `terminal_command_end`, `zone_switch`, `layout_snapshot` in `activity_vscode_events_impl()`. +- **Command categorizer**: `categorize_command()` with 50+ patterns across build, test, run, git, docker, deploy, install, navigate, debug, network. +- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category — shows how builds, tests, git, docker affect brain state. +- **`POST /v1/brain/context-cost`**: focus level at each zone transition type with switch counts. +- **`POST /v1/brain/dev-loops`**: edit-build-test cycle detection with iterations, pass rate, cycle time, focus trend. +- **`POST /v1/brain/terminal-commands`**: recent commands with exit codes, durations, categories, EEG correlation. +- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages". +- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl→infrastructure (0.8), deploy→deploying (0.85), git dominating→git_management (0.7), test→testing (0.85), debug→debugging (0.9). +- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end, `"switched to terminal"` on zone changes. +- **AI events table**: `ai_events` SQLite table tracking suggestion shown/accepted/rejected and chat sessions with source attribution. +- **OS-wide shell hooks**: `scripts/shell-hooks/` with preexec/precmd hooks for zsh, bash, fish, PowerShell. Sends every command to daemon via background curl. No delay to prompt. +- **Shell hook daemon endpoints**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to `~/.skill/shell-hooks/` and appends to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check. +- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). +- **`neuroskill brain` new subactions**: `terminal-impact`, `context-cost`, `dev-loops`. +- **`neuroskill activity` new subaction**: `terminal-commands`. +- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons, health indicators (green/yellow/red), recent commands preview, "How it works" documentation. +- **`neuroskill vscode` CLI**: auto-install extension to VS Code, VSCodium, or Cursor on macOS, Linux, and Windows. +- **Meeting nodes in search graph**: meetings appear as amber nodes in the interactive 3D search graph linked by `meeting_prox` edges. + +### i18n + +- Activity dashboard, clipboard, file history, session replay, weekly report, brain status, timeline, and code-brain correlation translations for all 9 locales. + +- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +- Added `umapSettings.backend`, `umapSettings.backendDesc`, `umapSettings.precision`, `umapSettings.precisionDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +- Added `dnd.grayscale` and `dnd.grayscaleDesc` translations to all 9 locales. + +- Search mode labels (Code, Meetings, Brain) translated in all 9 locales. +- Terminal command palette entries translated in all 9 locales. + +- **New `validation` namespace** in all 9 languages (`src/lib/i18n/{de,en,es,fr,he,ja,ko,uk,zh}/validation.ts`): ~95 keys in English (full coverage), ~55 in each other language for the user-visible strings (long-tail descriptions fall back via the runtime `t()` chain). `index.ts` barrel exports updated for every language. + +- **22 new `validation.*` keys per bundle** in all 9 languages (`bundle.l10n.{de,en,es,fr,he,ja,ko,uk,zh-cn}.json`): KSS prompt + 9 score labels with translated Karolinska wording, TLX/PVT prompts, all four escape-hatch labels, the disabled-permanently acknowledgement. +- **Two new `cmd.*` keys** for the validation commands in `package.nls.json`. + +- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +### Docs + +- VS Code extension design plan at `docs/vscode-extension.md`. +- `neuroskill-activity` skill documentation with 18 activity + 24 brain subcommands, terminal integration, shell hook reference, command categorization table. +- Updated `neuroskill-dnd` skill with grayscale mode. +- Updated `neuroskill/README.md` with terminal, brain awareness, and VS Code extension features. +- Updated `skills/SKILL.md` index with terminal tracking skill reference. + +- **VS Code extension README rewritten for skeptical developers**: scientific framing throughout — every derived metric (focus score, flow detector, deep-work minute, struggle predictor, optimal hours, fatigue) now ships with its formula, hyperparameters, and a "what can go wrong" line. Added an explicit *What this is, and isn't* section, a *Validation status* table tracking each metric's evidence level (production / pilot / heuristic / descriptive-only), and a *Validation roadmap* describing the planned KSS / NASA-TLX / PVT / EEG-fatigue calibration channels. Replaced the marketing pain-points table with a per-API signal taxonomy and an architecture paragraph. +- **References section with verified citations**: nine entries with DOIs cross-checked via CrossRef and `doi.org` redirects — Csikszentmihalyi 1990 (flow), Klimesch 1999 (α/θ oscillations), Lubar 1991 (θ/β attention ratio), Barry et al. 2005 (caffeine confound), Newport 2016 (deep work), Roenneberg et al. 2003 (chronotypes), Hart & Staveland 1988 + Hart 2006 (NASA-TLX), Jap et al. 2009 (EEG fatigue index). Each reference linked back to the metric it grounds. +- **Repository metadata pointed at the right repo**: discovered `extensions/vscode/` is a git submodule of `vscode-neuroskill`, not a subdirectory of the monorepo. Reverted `repository.url`, `bugs.url`, `homepage`, `qna` to the submodule's actual repo so the marketplace links resolve. + +### Dependencies + +- **burn-mlx**: added `burn-mlx` from git (`eidola-ai/burn-mlx`, branch `burn-0-20`) as optional dependency in `skill-router` and `skill-daemon`. Workspace `[patch.crates-io]` redirects all `burn-mlx` references to the git version (crates.io 0.1.2 targets burn 0.16; project uses burn 0.20). +- **macOS deployment target**: bumped from 10.15 to 14.0 in `.cargo/config.toml` for Metal 3.0 `simdgroup_matrix` intrinsics required by MLX. Still satisfies llama.cpp's `std::filesystem` (available since 10.15). +- **half 2.4**: added to `skill-router` for GPU f16 precision backend type (`CubeBackend`). + +- **Fix rand 0.7.3 vulnerability**: vendored `phf_generator 0.8.0` with rand bumped from 0.7 to 0.8, eliminating the vulnerable transitive dependency. +- **Remove atty**: migrated `iroh_test_client` from `structopt` (clap v2) to `clap v4` derive, dropping the unmaintained `atty` crate. +- **Update llama-cpp-4 to 0.2.50**: upstream llama.cpp fixes including `common_*` symbol renames. +- **Update kittentts to 0.4.1**: TTS engine update. +- **Add grayscale 0.0.1**: macOS grayscale display control for DND mode. diff --git a/Cargo.lock b/Cargo.lock index 83aecfee..21fbd4b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -908,9 +908,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.4" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", @@ -1862,9 +1862,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -2428,9 +2428,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" @@ -3311,9 +3311,9 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "dbus" @@ -3827,14 +3827,14 @@ dependencies = [ [[package]] name = "embed-resource" -version = "3.0.8" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -4074,9 +4074,9 @@ checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" [[package]] name = "espeak-ng" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf53da2d1e3049cfff5ae289a05b456c753c15cbe0bff2b97251aef5a748da94" +checksum = "797dcf553dc7581c714b28dc0f4caabb7bbb98ce3f5b70e3c9c2d2f2f7cce5b2" dependencies = [ "bitflags 2.11.1", "espeak-ng-data-dict-ru", @@ -4217,9 +4217,9 @@ dependencies = [ [[package]] name = "fancy-regex" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" dependencies = [ "bit-set 0.8.0", "regex-automata", @@ -4297,23 +4297,9 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -5884,9 +5870,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" dependencies = [ "typenum", ] @@ -6154,9 +6140,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -6254,9 +6240,9 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" [[package]] name = "indexmap" @@ -6746,6 +6732,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -6801,9 +6817,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ "cfg-if", "futures-util", @@ -6835,15 +6851,15 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.46.2" +version = "0.46.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50180452e7808015fe083eae3efcf1ec98b89b45dd8cc204f7b4a6b7b81ea675" +checksum = "cbe92a2f8b00686061eab5cdcfd6f382c27f2084456e7be90ae9f0fe4a30552a" dependencies = [ "ahash", "bytecount", "data-encoding", "email_address", - "fancy-regex 0.17.0", + "fancy-regex 0.18.0", "fraction", "getrandom 0.3.4", "idna", @@ -7274,10 +7290,11 @@ dependencies = [ [[package]] name = "llmfit-core" -version = "0.9.14" +version = "0.9.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e0d5d303daeb9a26f414d6e97cc123fa924314177bba06b4dd03b65a2313f0" +checksum = "f5eb09b5be6bade227582226bd0d74069abd88460756af4e93a8dfe97c38d57c" dependencies = [ + "dirs", "http 1.4.0", "serde", "serde_json", @@ -8482,7 +8499,7 @@ dependencies = [ "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus 5.14.0", + "zbus 5.15.0", ] [[package]] @@ -9169,9 +9186,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onig" -version = "6.5.1" +version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ "bitflags 2.11.1", "libc", @@ -9181,9 +9198,9 @@ dependencies = [ [[package]] name = "onig_sys" -version = "69.9.1" +version = "69.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" dependencies = [ "cc", "pkg-config", @@ -9324,6 +9341,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2c1f9f56e534ac6a9b8a4600bdf0f530fb393b5f393e7b4d03489c3cf0c3f01" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -9909,13 +9935,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.38.4", + "quick-xml 0.39.2", "serde", "time", ] @@ -10392,15 +10418,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.39.2" @@ -10842,9 +10859,9 @@ dependencies = [ [[package]] name = "referencing" -version = "0.46.2" +version = "0.46.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acb0c66c7b78c1da928bee668b5cc638c678642ff587faff6e6222f797be9d4c" +checksum = "e125f10bdcd507598c702daada18c47fe5bfba4d7a9545b015b5d432f7168ca3" dependencies = [ "ahash", "fluent-uri", @@ -10988,9 +11005,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", @@ -11402,9 +11419,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -11438,9 +11455,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -11448,13 +11465,13 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni 0.21.1", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -12094,6 +12111,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -12132,7 +12159,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.129" +version = "0.0.130-rc.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -12521,6 +12548,7 @@ dependencies = [ "base64 0.22.1", "image", "iroh", + "pkcs8", "qrcodegen", "rand 0.9.4", "serde", @@ -13542,7 +13570,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.2", + "reqwest 0.13.3", "serde", "serde_json", "serde_repr", @@ -13696,7 +13724,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows 0.61.3", - "zbus 5.14.0", + "zbus 5.15.0", ] [[package]] @@ -13721,7 +13749,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus 5.14.0", + "zbus 5.15.0", ] [[package]] @@ -13740,7 +13768,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.13.2", + "reqwest 0.13.3", "rustls", "semver", "serde", @@ -13848,13 +13876,13 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -15453,9 +15481,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -15466,9 +15494,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ "js-sys", "wasm-bindgen", @@ -15476,9 +15504,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -15486,9 +15514,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -15499,9 +15527,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -15656,9 +15684,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", @@ -15989,7 +16017,7 @@ dependencies = [ "naga", "ndk-sys", "objc", - "ordered-float 4.6.0", + "ordered-float 5.0.0", "parking_lot", "portable-atomic", "portable-atomic-util", @@ -16675,9 +16703,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" @@ -17016,7 +17041,7 @@ dependencies = [ "widestring", "windows 0.62.2", "xcb", - "zbus 5.14.0", + "zbus 5.15.0", ] [[package]] @@ -17160,9 +17185,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ "async-broadcast", "async-executor", @@ -17187,10 +17212,10 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.15", - "zbus_macros 5.14.0", - "zbus_names 4.3.1", - "zvariant 5.10.0", + "winnow 1.0.2", + "zbus_macros 5.15.0", + "zbus_names 4.3.2", + "zvariant 5.10.1", ] [[package]] @@ -17208,17 +17233,17 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", - "zbus_names 4.3.1", - "zvariant 5.10.0", - "zvariant_utils 3.3.0", + "zbus_names 4.3.2", + "zvariant 5.10.1", + "zvariant_utils 3.3.1", ] [[package]] @@ -17234,13 +17259,13 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 0.7.15", - "zvariant 5.10.0", + "winnow 1.0.2", + "zvariant 5.10.1", ] [[package]] @@ -17505,16 +17530,16 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.0" +version = "5.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.15", - "zvariant_derive 5.10.0", - "zvariant_utils 3.3.0", + "winnow 1.0.2", + "zvariant_derive 5.10.1", + "zvariant_utils 3.3.1", ] [[package]] @@ -17532,15 +17557,15 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils 3.3.0", + "zvariant_utils 3.3.1", ] [[package]] @@ -17556,13 +17581,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow 0.7.15", + "winnow 1.0.2", ] diff --git a/changes/releases/0.0.130-rc.1.md b/changes/releases/0.0.130-rc.1.md new file mode 100644 index 00000000..f1f4b4ff --- /dev/null +++ b/changes/releases/0.0.130-rc.1.md @@ -0,0 +1,769 @@ +## [0.0.130-rc.1] — 2026-04-29 + +### Features + +- **Human vs AI activity tracking**: every edit and commit is now classified as `source: "human"` or `source: "ai"` in real-time. `AIActivityTracker` watches VSCode commands (Copilot/Codeium completions, inline chat, AI-generated commit messages) to tag subsequent edits/commits, exposes `getAIRatioForFile()` (rolling 5-minute ratio) for CodeLens + sidebar, and forwards the `source` field to the daemon for `build_events` / `ai_events` storage. + +## How it works + +The `AIActivityTracker` monitors VSCode command execution to detect AI tool usage: + +- **Inline completions** (Copilot, Codeium) — edits within 5 seconds after an AI command are tagged `source: "ai"` +- **Inline chat** (`inlineChat.start`, `copilot.interactiveEditor.*`) — subsequent edits in the same file are tagged AI +- **Commit messages** — `github.copilot.git.generateCommitMessage` marks the next commit as AI-assisted +- **Everything else** — classified as `source: "human"` + +## What's tracked + +| Signal | Classification | +|--------|---------------| +| Manual typing | `human` | +| Copilot inline suggestion accepted | `ai` | +| Copilot inline chat edits | `ai` | +| Paste from external source | `human` | +| AI-generated commit message | `ai` | +| Manually typed commit message | `human` | + +## Per-file AI ratio + +`AIActivityTracker.getAIRatioForFile(path)` returns a rolling 5-minute ratio (0.0 = all human, 1.0 = all AI) used by: +- CodeLens annotations (shows "AI-Assisted" vs focus score) +- Sidebar (Human/AI percentage display) +- Brain status command (Human/AI split) + +## Daemon integration + +The `source` field is sent on every `edit` and `git_commit` event to the daemon. The daemon stores: +- AI commits as `"git commit (ai-assisted)"` in `build_events` +- AI commits also as `ai_events` for analytics weighting +- Completion acceptances as `ai_events` with type `"suggestion_accepted"` + +## Files + +- `src/ai-tracker.ts` — Core tracker (new) +- `src/events.ts` — Wired to classify edits and commits +- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Daemon-side storage + +- **Focus-aware code review CodeLens**: annotations at the top of each file show the developer's focus level when the file was last edited (`⚠ Low Focus (42)`, `ℹ Focus: 65/100`, `🤖 AI-Assisted (85%)`, or none for high focus). `NeuroSkill: Show Files Needing Review` command lists low-focus human-authored files. Toggle via `neuroskill.focusCodeLens`. + +## What you see + +- `⚠ Low Focus (42) — Review Recommended` — File was edited during low focus. Click to see all files needing review. +- `ℹ Focus: 65/100` — Moderate focus, informational only. +- `🤖 AI-Assisted (85%)` — Most edits were AI-generated, focus score not applicable. +- No annotation — High focus (>70) or no data yet. + +## Commands + +**NeuroSkill: Show Files Needing Review** (`Cmd+Shift+P`) +- Shows a QuickPick list of files edited during low focus (<50) that were mostly human-authored +- Sorted by focus score (lowest first) +- Select a file to open it + +## How it works + +- `FocusCodeLensProvider` queries `/brain/cognitive-load` (grouped by file) every 30 seconds +- Combines focus data with `AIActivityTracker.getAIRatioForFile()` to distinguish human vs AI code +- Files with high AI ratio (>70%) show AI label instead of focus score — AI code doesn't reflect human cognitive state + +## Settings + +`neuroskill.focusCodeLens` (default: `true`) — Toggle CodeLens annotations on/off. + +## Files + +- `src/codelens-provider.ts` — CodeLens provider (new) + +- **Smart Interruption Shield**: when `/brain/flow-state` reports `in_flow: true`, VS Code's Do Not Disturb mode auto-enables and the status bar shows `$(shield) In Flow 12m`. `NeuroSkill: Toggle Flow Shield` cycles Auto / Forced-on / Forced-off. Toggle via `neuroskill.flowShield`. + +## How it works + +- When `/brain/flow-state` reports `in_flow: true`, the Flow Shield activates +- Enables VSCode's Do Not Disturb mode (VSCode 1.90+) +- Shows `$(shield) In Flow 12m` in the status bar with elapsed time +- When flow state ends, DND is automatically disabled + +## Manual override + +**NeuroSkill: Toggle Flow Shield** (`Cmd+Shift+P`) + +Cycles through three modes: +1. **Auto** (default) — activates/deactivates based on EEG flow detection +2. **Forced on** — always active regardless of flow state +3. **Forced off** — never active + +## Settings + +`neuroskill.flowShield` (default: `true`) — Enable/disable the flow shield feature. + +## Files + +- `src/flow-shield.ts` — Flow shield implementation (new) +- `src/brain.ts` — Calls `flowShield.update()` every 30s + +- **Adaptive Break Coach**: personalized break timing based on the developer's actual EEG focus cycle (via `/brain/break-timing`), not generic Pomodoro. Status bar countdown `$(clock) Break in 8m`, max one notification per cycle, auto-resets when fatigue indicates idleness. `NeuroSkill: Take a Break` resets the timer manually. Toggle via `neuroskill.breakCoach`. + +## How it works + +- Queries `/brain/break-timing` to learn the developer's natural focus cycle length +- Shows a countdown in the status bar: `$(clock) Break in 8m` +- When the predicted focus drop is imminent (<5 min), the countdown turns visible +- When the cycle ends, shows `$(clock) Break time` and optionally notifies + +## Notifications + +- Max one notification per focus cycle +- Message: "You've been focused for 47m. Your natural cycle is 42m — take a break?" +- Buttons: "Take Break" (resets timer) or "Dismiss" + +## Timer sync + +The break coach automatically syncs with the daemon's fatigue data. If `continuous_work_mins` drops below 5 (indicating the user was idle), the session timer resets — no false break suggestions after returning from lunch. + +## Commands + +**NeuroSkill: Take a Break** (`Cmd+Shift+P`) — Manually acknowledge a break and reset the timer. + +## Settings + +`neuroskill.breakCoach` (default: `true`) — Enable/disable break coaching. + +## Files + +- `src/break-coach.ts` — Break coach implementation (new) +- `src/brain.ts` — Calls `breakCoach.refresh()` and `resetSessionIfIdle()` every 30s + +- **Struggle → AI assist bridge**: when `/brain/struggle-predict` flags a file (EEG focus + undo rate + velocity drop + time-on-file), shows an actionable notification with **Open Copilot Chat**, **Open Terminal**, and **Step Back** buttons. Debounced to one suggestion per file per 10 minutes. Toggle via `neuroskill.struggleBridge`. + +## How it works + +- Monitors `/brain/struggle-predict` (EEG focus + undo rate + velocity drop + time-on-file) +- When `struggling: true`, shows an actionable notification: + > "Stuck on auth.ts? (score: 78) Consider breaking the problem into smaller pieces." + +## Action buttons + +| Button | Action | +|--------|--------| +| **Open Copilot Chat** | Opens GitHub Copilot interactive chat (or generic chat panel) | +| **Open Terminal** | Toggles terminal for CLI debugging | +| **Step Back** | Dismiss and take a mental break | + +## Debouncing + +- Max one suggestion per file per 10 minutes +- Prevents notification fatigue while still catching genuine struggles + +## Settings + +`neuroskill.struggleBridge` (default: `true`) — Enable/disable struggle detection and AI suggestions. + +## Files + +- `src/struggle-bridge.ts` — Struggle bridge implementation (new) +- `src/brain.ts` — Calls `struggleBridge.check()` every 30s (replaces the old generic struggle notification) + +- **Personal Flow Triggers dashboard**: collapsible "Your Flow Recipe" sidebar section mining 7-day EEG + activity history — best language (`/brain/code-eeg`), peak hours (`/brain/optimal-hours`), natural cycle length (`/brain/break-timing`), top flow killer (`/brain/context-cost`). Toggle via `neuroskill.flowTriggers`. + +## What you see + +In the NeuroSkill sidebar panel, a collapsible "Your Flow Recipe" section shows: + +- **Best language** — "Focus best on Rust (82)" — from `/brain/code-eeg` +- **Peak hours** — "Peak hours: 9:00, 10:00, 14:00" — from `/brain/optimal-hours` +- **Natural cycle** — "Natural cycle: 42m" — from `/brain/break-timing` +- **Flow killer** — "Flow killer: Slack (focus 38 at switch)" — from `/brain/context-cost` + +## Data sources + +| Insight | API Endpoint | Time Range | +|---------|-------------|------------| +| Best languages | `/brain/code-eeg` | Last 7 days | +| Peak hours | `/brain/optimal-hours` | Last 7 days | +| Natural cycle | `/brain/break-timing` | Last 7 days | +| Flow killers | `/brain/context-cost` | Last 7 days | + +## Settings + +`neuroskill.flowTriggers` (default: `true`) — Show/hide the flow triggers section in the sidebar. + +## Files + +- `src/sidebar.ts` — `_fetchFlowTriggers()` and `_renderFlowTriggers()` methods + +- **Focus-scored git commits in sidebar**: collapsible "Recent Commits" section shows the last 8 commits with author marker (👤 human / 🤖 AI) and a color-coded focus badge captured at commit time via `/brain/flow-state`. AI-assisted commits show "AI" instead of a focus score. Toggle via `neuroskill.focusCommits`. + +## What you see + +In the NeuroSkill sidebar, a collapsible "Recent Commits" section shows the last 8 commits: + +``` +👤 82 fix: resolve auth race condition +👤 45 chore: update dependencies +🤖 AI refactor: extract helper functions +👤 71 feat: add user preferences +``` + +- **👤** = human-authored commit +- **🤖** = AI-assisted commit (message generated by Copilot) +- **Focus badge** = color-coded: green (>70), yellow (40-70), red (<40) +- AI commits show "AI" instead of a focus score — AI output doesn't measure human cognition + +## How it works + +- When the extension detects a git commit (SCM input box clears), it: + 1. Snapshots current EEG focus via `/brain/flow-state` + 2. Checks `AIActivityTracker.isCommitAIAssisted()` + 3. Records the commit with focus score + source label +- Commits stored in-memory (last 15), refreshed on sidebar render +- The daemon also stores commits with human/AI distinction in `build_events` + +## Settings + +`neuroskill.focusCommits` (default: `true`) — Show/hide the commits section in the sidebar. + +## Files + +- `src/sidebar.ts` — `recordCommit()`, `_renderCommits()` +- `src/extension.ts` — Wires commit detection to sidebar recording +- `src/events.ts` — `onCommit` callback with human/AI source + +- **Optimal Task Router**: monitors flow score every 30 s and, when it changes by >20 points, suggests an appropriate task type (refactoring/new features at high focus, code review at moderate, docs/routine at low). Debounced to one suggestion per 15 minutes. Toggle via `neuroskill.taskRouter`. + +## How it works + +- Monitors the flow state score every 30 seconds +- When focus changes by >20 points from the last reading, suggests an appropriate task type: + +| Focus Level | Suggestion | +|------------|------------| +| >75 | "Focus is high (85) — great time for complex work like refactoring or new features." | +| 45-75 | "Focus moderate (58) — good for code review, testing, or incremental tasks." | +| <45 | "Focus low (32) — consider documentation, routine tasks, or a break." | + +## Debouncing + +- Maximum one suggestion every 15 minutes +- No suggestion on the first reading (establishes baseline) +- No suggestion if focus stays within 20 points of the last reading + +## Settings + +`neuroskill.taskRouter` (default: `true`) — Enable/disable task routing suggestions. + +## Files + +- `src/task-router.ts` — Task router implementation (new) +- `src/brain.ts` — Calls `taskRouter.check()` every 30s + +- **Activity dashboard tab**: new Settings tab with daily summary, productivity score (0-100 composite), hourly heatmap, top files/projects, language breakdown, focus sessions, meetings, and stale file alerts. +- **Focus session replay**: click any session to expand a detailed view with file timeline, edit counts, EEG focus overlay bar, and meeting interruptions. +- **Weekly report with CSV export**: 7-day activity digest with daily breakdown chart and one-click CSV download. +- **Productivity scoring**: composite score from edit velocity + deep work time + context stability + EEG focus. +- **Stale file detection**: files edited but untouched for 7+ days. +- **Code-Brain correlation**: focus ranked by language, project, and individual files — shows which code drains or energizes you. +- **Activity timeline**: unified chronological feed of all events (files, builds, meetings, AI, clipboard) with EEG focus indicators. + +- **Brain awareness API**: 14 endpoints under `/v1/brain/*` — flow state, cognitive load, meeting recovery, optimal hours, fatigue check, undo struggle, daily brain report, break timing, deep work streak, task type detection, struggle prediction, interruption recovery, code-EEG correlation, and unified timeline. +- **Flow state detector**: real-time check if user is in deep focus (high focus + low switches + sustained editing). +- **Task type detection**: auto-classify coding/debugging/reviewing/refactoring/testing from activity patterns. +- **Struggle prediction**: fuse undo rate + velocity drop + focus decline to predict when user is stuck. Includes actionable suggestion. +- **Interruption recovery measurement**: measure actual focus recovery time per interruption source (Slack avg 12min, Zoom avg 23min, etc.). +- **Fatigue monitor**: 15-minute background check broadcasts `fatigue-alert` and sends OS notification when focus declines. +- **Daily brain report**: 6pm OS notification with morning/afternoon/evening brain summary. +- **Weekly digest notification**: Monday 9am OS notification with weekly summary. ISO week dedup prevents re-fire on daemon restart. +- **Break timing optimizer**: detect natural focus cycle length from 5-minute EEG buckets. +- **Deep work streak**: gamified consecutive days with 60+ minutes of deep work. + +- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable EEG-fused insights in one call. +- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — shows how focus level predicts test outcomes. +- **Hourly productivity**: churn volume + undo rate + avg EEG focus by hour of day — identifies peak and trough hours. +- **Context switch recovery**: EEG focus level at each editor/terminal/panel transition — measures the cognitive cost of switching. +- **AI tool impact on focus**: per-app (Claude, Pi) focus delta vs baseline — quantifies whether AI tools help or hurt flow state. +- **Focus by language**: avg EEG focus + undo rate per programming language — identifies which languages are cognitively easiest/hardest. +- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day — shows when edit-test loops are fastest. +- **Tool focus impact**: avg EEG focus per command category (docker, git, deploy, etc.) — reveals which tools correlate with lowest focus. +- **EEG timeseries table**: periodic JSON snapshots of all brain metrics (every 5s during recording). Extensible — new metrics without schema changes. Any event correlatable by timestamp join. +- **EEG timeseries worker**: background thread writes full band powers to `eeg_timeseries` every 5s when an EEG session is active. +- **`POST /v1/brain/eeg-at`**: get EEG metrics at a specific timestamp (nearest sample). +- **`POST /v1/brain/eeg-range`**: get EEG time-series in a range for charts and correlation analysis. +- **Window focus tracking**: `window_focus` events sent when VS Code gains/loses focus. EEG not attributed to coding when VS Code is in background. +- **Live EEG attachment**: `latest_bands` focus/mood injected at event storage time for terminal commands, zone switches, and conversations. + +- **`mlx-e2e` test suite**: new suite in `scripts/test-all.sh` running UMAP and FFT e2e tests with MLX features. Auto-skips on non-macOS. Available via `npm run test:mlx-e2e`. + +- **Conversations table**: stores all AI coding assistant messages (Claude, Pi) with app, role (user/assistant/tool), text, cwd, timestamp, session ID, EEG focus/mood. +- **FTS5 full-text search**: SQLite virtual table for instant keyword search across all conversation text. `POST /v1/brain/search-conversations {"query":"JWT","mode":"fts"}`. +- **Fuzzy search**: LIKE-based substring matching for partial queries. `{"query":"rate limit","mode":"fuzzy"}`. +- **Structured search**: filter by app, role, time range. `{"mode":"structured","app":"claude","role":"user","since":...}`. +- **Semantic search**: user prompts embedded via fastembed (nomic-embed-text-v1.5, local, no API credits) and stored in HNSW label index. Searchable by meaning, not just keywords. +- **Generic embedding store**: `embeddings` table decoupled from specific data tables. Multi-model support — can re-embed with different models, store multiple vectors per item. Source tracking (source_type, source_id). +- **Code context HNSW index**: separate `code_context_index.hnsw` file for code-specific semantic search, keeping EEG label searches uncontaminated. +- **Conversation events**: VS Code extension sends `conversation_message` events to daemon for each Claude/Pi message. User prompts get embedded; assistant responses and tool calls get FTS-indexed only (saves compute). +- **Session tracking**: messages grouped by JSONL filename (session ID). Timestamps from app's own data (ISO 8601 UTC), not extension read time. +- **Embedding settings**: `neuroskill.embedding.maxInputLength` (default 1000 chars) and `neuroskill.embedding.enableConversations` (default true) in VS Code settings. + +- **Meeting detection**: automatically detect Zoom, Teams, Slack, Google Meet, FaceTime, Discord, and Webex meetings from window titles. Track start/end times in `meeting_events` table. +- **Browser tab extraction**: extract page titles from Chrome, Safari, Firefox, Edge, Brave, Arc, Opera, Vivaldi, Chromium. Stored in `browser_title` column on `active_windows`. +- **Clipboard monitoring**: opt-in macOS clipboard change tracking (metadata only — content never stored). Records source app, content type, and size. Includes Automation permission check and settings UI. +- **Multi-monitor awareness**: track windows on secondary monitors across macOS (AppleScript), Linux (wmctrl), Windows (EnumWindows). Dynamic primary screen resolution detection. +- **Undo frequency tracking**: detect undo/redo from file edit diffs via reversal heuristic. `undo_estimate` per 5-second chunk, `undo_count` per file interaction. + +- **Design tokens** (`webview-ui/src/lib/tokens.css`): 30+ CSS custom properties organized into core palette (5 colors), surfaces (4 levels), backgrounds (14 translucent tints), borders (3 colors), semantic aliases (7), and typography (3). Zero inline `rgba()` in App.svelte. +- **Reusable Svelte components** (`webview-ui/src/lib/`): + - `Card` — wrapper with title, info button, info panel, header-right slot, variant borders (warn/danger/info) + - `MetricRow` — label + value pair with variant colors (warn/accent/dim/good/bad) + - `Chevron` — collapsible section with chevron toggle, count badge, slot content + - `ProgressBar` — horizontal bar with value/max, 5 color variants, optional label + - `Gauge` — circular SVG ring with animated fill, value, label + - `Badge` — text badge with 7 variants (default/good/warn/bad/blue/live/score-circle/si) + - `Callout` — alert box with 3 variants (warn/danger/info) +- **Component index** (`webview-ui/src/lib/index.ts`): barrel export for all components. +- **Timestamp compliance**: 25 automated tests (`src/test-timestamps.ts`) verifying: + - No `toLocaleTimeString`/`toLocaleDateString` in data layer (activity-tracker, events, brain, config, vt-parser) + - `toLocaleTimeString` used in UI layer (App.svelte) for display + - `Date.now()` returns UTC milliseconds + - ISO 8601 strings parsed to UTC millis + - No hardcoded timezone offsets in data layer + - All stored timestamps are UTC; local conversion only at UI boundary + +- **Dev loop detection**: identifies edit-build-test cycles from terminal command history. Groups consecutive runs of the same build/test command into loops. +- **`dev_loops` table**: loop type, command, iteration count, pass/fail counts, avg cycle time, fastest/slowest cycle, EEG focus start/end, focus trend (rising/falling/stable). +- **`POST /v1/brain/dev-loops`**: returns detected loops for a time window with all metrics. +- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages." +- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl → infrastructure (0.8), deploy → deploying (0.85), git dominating → git_management (0.7), test → testing (0.85), debug → debugging (0.9). +- **Dev loops in sidebar**: command, iteration count, pass rate, avg cycle time, focus trend arrow. Failing loops get red left border. +- **Dev loops in Tauri Activity tab**: expanded view with pass rate percentage, cycle time, focus trend. +- **Loop efficiency by hour**: insight showing build/test cycle time + pass rate by time of day. + +- **Inference backend selector**: `exg_inference_device` setting expanded from `gpu | cpu` to `auto | mlx | gpu | cpu`. Default changed from `gpu` to `auto` (picks MLX on macOS, GPU elsewhere). +- **Four-button selector in EXG Settings**: Auto, MLX, GPU, CPU — each with localized label and description. Reconnect headset hint shown after change. +- **`embed-exg-mlx` feature**: new daemon Cargo feature that enables `skill-router/mlx`, `skill-eeg/mlx`, and `embed-zuna-mlx` together. + +- **fast-umap 1.6.0**: updated from 1.5.1 with MLX, PCA, and nearest-neighbor descent support. +- **MLX UMAP backend**: Apple Silicon native UMAP projection via `burn-mlx`. Runtime dispatch between MLX and GPU (wgpu) based on user preference. Auto defaults to MLX on macOS, GPU elsewhere. +- **Precision selector**: F32 / F16 precision for the GPU (wgpu) backend. MLX is F32-only (fast-umap trait constraint). Exposed in UMAP settings as chip group. +- **Backend & precision UI**: new "Compute Backend" section in UMAP settings with Auto / MLX / GPU chips and F32 / F16 precision chips. Pipeline summary badge shows active backend and precision. + +- **UMAP e2e benchmarks**: `umap_e2e_small` (200 pts), `umap_e2e_medium` (1K pts), `umap_e2e_large` (5K pts) with synthetic 32-dim EEG embeddings. Reports backend, timing, throughput (pts/sec), and separation score. + +- **gpu-fft 1.2.0**: updated from 1.1.1 with MLX FFT backend. EEG signal filtering (overlap-save convolution) uses MLX when `skill-eeg/mlx` is enabled. ~3.7x faster than wgpu at N=65536. +- **`skill-eeg/mlx` feature**: new feature gate that enables `gpu-fft/mlx` alongside `gpu-fft/wgpu`. Wired into `embed-exg-mlx` daemon feature. + +- **FFT MLX e2e**: `fft_e2e_roundtrip_256`, `fft_e2e_batch_4ch`, `fft_e2e_psd_peak_detection`, `fft_e2e_large_batch` (32 channels x 1024 samples). Validates round-trip accuracy, PSD peak detection, and batch throughput. + +- **Grayscale display mode**: optional system-wide grayscale that activates/deactivates in sync with Do Not Disturb. Reduces visual distraction during deep work. macOS only, default off. + +- **File activity in history sessions**: expanded EEG sessions show files worked on during the session with focus indicators, edit counts (+/-), language, and meeting interruption badges. +- **File activity in interactive search**: `file_activity` nodes in the 3D search graph linked to EEG epochs and text labels, showing which files were being edited during matching brain states. +- **Meeting nodes in search graph**: detected meetings appear alongside EEG results in the interactive search as amber nodes with temporal proximity edges. + +- **Git context card**: current branch (monospace), dirty/staged file count, ahead/behind indicators. Uses VS Code git extension API. +- **Session timeline card**: horizontal bar chart showing activity events by hour of day. Color-coded by volume. +- **Workspace activity card**: per-file edits, lines added/removed, focus time. Grouped by workspace folder (project), sorted by activity. Active file indicator (blue dot). Top 10 files per project. +- **Environment card**: editor/terminal/panel time split (colored stacked bar), tab count, editor groups, terminal count. Per-terminal cards with shell type, CWD, PID, shell integration status. +- **Terminal I/O sections**: collapsible with chevrons, minimized by default. Input shows timestamped commands with CWD. Output shows VT-parsed session content. +- **Terminal input card**: keystroke intensity per program (keys/min), duration, collapsible behind chevron. +- **Terminal impact card**: EEG focus delta by command category with pass rate percentage. +- **Context switch cost card**: focus level at each zone transition type with switch count. +- **Dev loops card**: edit-build-test cycles with iteration count, pass/fail rate, cycle time, focus trend (rising/falling/stable arrow). +- **Today's report card**: productivity score, morning/afternoon/evening period breakdown with focus bars and churn. +- **Energy card**: fatigue bar (inverse of focus decline), continuous work time, streak badge. +- **Struggle card**: EEG-only (hidden without recording), score 0-100, contributing factors. +- **Optimal hours card**: peak/avoid hours grid. +- **AI usage card**: acceptance rate bar, suggestion count, source breakdown. +- **Today vs yesterday card**: files and churn comparison with directional arrows. +- **Code review detection**: auto-detected when file switches > 5 and edit velocity < 1 line/min. +- **Process monitor card**: running dev servers (vite, next, webpack, cargo, node, python, docker, postgres) with ports and PIDs. +- **Info toggles**: every card has a `?` button explaining how metrics are calculated. +- **Dual-speed updates**: local data every 5s (visible), brain data every 30s. Pauses when sidebar hidden. + +- **OS-wide shell hooks**: preexec/precmd hooks for zsh, bash, fish, PowerShell. Background curl sends every command to daemon — zero delay to prompt. Self-contained scripts generated by daemon, stored in `~/.skill/shell-hooks/`. +- **Shell hook management**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check per shell. +- **Terminal command table**: `terminal_commands` with command text, binary name, args, cwd, exit code, duration, auto-categorized (284 CLI tools across 18 categories), EEG focus at start + end for delta calculation. +- **Binary extraction**: raw binary name stored separately from args. Lazy categorization — `POST /v1/brain/recategorize-commands` reruns rules on all historical data. `POST /v1/brain/binary-stats` discovers uncategorized tools by frequency. +- **284 CLI tools recognized**: git, gh, glab, hf, docker, kubectl, helm, aws, gcloud, az, brew, pip, cargo, npm, psql, redis-cli, ollama, claude, pi, vim, tmux, htop, terraform, and 260+ more across 18 categories (build, test, run, git, docker, deploy, install, navigate, debug, network, database, ai, editor, multiplexer, monitor, env, system, other). +- **Script session recording**: `script` PTY proxy wraps shell sessions, captures all terminal I/O (including input inside interactive programs). Logs stored in `~/.skill/terminal-logs/` with auto-rotation. +- **VT100 terminal emulator**: `vt-parser.ts` replays script recordings through a virtual terminal with scrollback capture. Handles cursor movement, screen clears, alternate screen buffer. Separates input from output via prompt pattern detection. +- **App-specific JSONL parsing**: reads Claude Code (`~/.claude/projects/`) and Pi agent (`~/.pi/agent/sessions/`) conversation files directly. Extracts user prompts with real timestamps, assistant responses, and tool calls. Supports multiple parallel sessions. +- **Readline history polling**: monitors `~/.python_history`, `~/.node_repl_history`, `~/.irb_history`, `~/.psql_history` for REPL input. +- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). +- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons with health indicators (green/yellow/red dot), recent commands preview, "How it works" documentation. + +- **Validation / fatigue-research daemon backend**: foundations for calibrating the Break Coach and Focus Score against four external instruments — KSS (Karolinska Sleepiness Scale), NASA-TLX (workload), PVT (Psychomotor Vigilance Task), and an EEG-derived fatigue index per Jap et al. 2009. +- **`skill-data::validation_store`** — new SQLite store at `~/.skill/validation.sqlite` with five tables (`config`, `kss_responses`, `tlx_responses`, `pvt_runs`, `prompt_log`). Persistent config is a single-row JSON blob with serde defaults so adding a new channel later is a non-migration. New constant `VALIDATION_FILE` in `skill-constants`. +- **EEG fatigue index**: pure function `eeg_fatigue_index(bands) → Option` computing `(α + θ) / β` over the existing band-power snapshot. Guards against missing bands and zero-β. Passive; ships **on** because it costs nothing when no headset is attached. +- **Pure scheduler `decide_prompt(ctx, ...) → PromptDecision`**: respects the `respect_flow` master gate, configurable quiet hours, per-channel daily caps, runtime snoozes, and rate limits. Channel ordering: KSS first (lightest), TLX after a long task unit, PVT on a weekly cadence. Live `read_in_flow` and `read_break_coach_active` open the activity store read-only and ask `flow_state_now(300).in_flow` and `fatigue_check().fatigued` so the gates actually mean something. +- **Sane defaults — opt-in everywhere**: KSS, TLX, PVT all ship `enabled = false`. Only the passive EEG fatigue index ships on. The `respect_flow` master gate ships on. + +- **Validation & Research settings tab** (`src/lib/settings/ValidationTab.svelte`): new top-level settings tab gathering five `SettingsCard` blocks — global gates (`respect_flow`, quiet hours), KSS, NASA-TLX, PVT, and EEG fatigue index — plus a Calibration Week button and a recent-results card. + - Every toggle / numeric input PATCHes one nested field via `daemonPatch("/v1/validation/config", …)`; the daemon's recursive JSON merge keeps the rest of the config untouched. + - EEG fatigue card shows the live `(α + θ) / β` value polled every 5 s from `/v1/validation/fatigue-index`. + - Calibration Week: single button that batch-updates all four channels to higher-frequency presets (KSS 8/day, TLX after every flow block ≥ 20 min, PVT mid-week) in one PATCH. +- **PVT panel** (`src/lib/settings/PvtPanel.svelte`): full 3-minute Psychomotor Vigilance Task. Random ITIs (2–10 s), green-dot stimulus, `performance.now()` for sub-millisecond RT measurement, tracks responses + false starts. On finish computes mean RT, median RT, slowest-10% mean RT (anticipation-resistant), and lapse count (RT > 500 ms per Dinges & Powell 1985), POSTs to `/v1/validation/pvt`. State machine: intro → running → done. +- **NASA-TLX form** (`src/lib/settings/TlxForm.svelte`): six-slider modal for the raw (un-weighted) NASA-TLX. Performance scale uses inverted endpoints ("Failure" → "Perfect") per Hart 2006. POSTs to `/v1/validation/tlx` with `task_kind`, `task_duration_secs`, optional `prompt_id` echo-back. +- **Pure stats helper** (`src/lib/settings/pvt-stats.ts`): extracted `mean`, `median`, `slowest10Mean`, `lapseCount`, `computeStats` from the PVT panel so the math is unit-testable without a Svelte renderer. +- **`daemonPatch(path, body)`** in `src/lib/daemon/http.ts` — needed for the validation config endpoint. + +- **Test coverage for the validation feature across all three layers** — 72 tests total, all passing. + - **Rust unit tests** (`crates/skill-data/src/validation_store.rs`): 19 tests covering config persistence, store round-trips, the `(α + θ) / β` fatigue index (Jap et al. 2009) including zero-β and missing-band edge cases, every scheduler branch (flow respect, quiet hours, snooze blocking, KSS rate limit, TLX trigger after long task, TLX skip on short task, PVT weekly cadence, PVT silence after recent run, quiet-window-equals-end semantics), and prompt-log queries. + - **Rust HTTP integration tests** (`crates/skill-daemon/src/routes/validation.rs`): 10 tests using `tower::ServiceExt::oneshot()` against a tempdir-backed `AppState` — `GET /config` defaults, `PATCH /config` partial-update + round-trip, `POST /kss` happy path, KSS score-out-of-range → 400, TLX subscale-out-of-range → 400, `GET /should-prompt` returns `{kind: "none"}` with default config, `POST /snooze` envelope, plus three `merge_json` tests covering deep nesting. + - **Vitest — PVT statistics** (`src/tests/pvt-stats.test.ts`, 12 tests): edge cases for `mean`, `median`, `slowest10Mean` (including the n<10 fallback), `lapseCount` with the 500 ms Dinges & Powell threshold and explicit-threshold override, plus a known-fixture `computeStats` round-trip. + - **Vitest — i18n coverage** (`src/tests/validation-i18n.test.ts`, 23 tests): every VS Code l10n bundle exists and contains every user-facing key (KSS prompt + scores 1/5/9, TLX/PVT prompts, all four escape-hatch labels); every Tauri language `validation.ts` imports cleanly; English Tauri bundle covers every required key; every non-English Tauri bundle has at least the tab name and disclaimer translated. + +- **VS Code extension joins the validation prompt loop**: new `ValidationManager` (`extensions/vscode/src/validation.ts`) polls `/v1/validation/should-prompt` every 90 s and renders whichever channel the daemon decides to fire. + - **KSS** (1–9 sleepiness) — full QuickPick with Karolinska wording, plus Snooze 30m / Don't ask today / Stop these prompts escape hatches in-line. POSTs the answer to `/v1/validation/kss` echoing the daemon's `prompt_id` so the prompt log can mark it answered. + - **NASA-TLX** — fallback path: shows an information message offering to open the form in the Tauri app (deep link `neuroskill://validation/tlx`) plus the same escape hatches. + - **PVT** — weekly nudge: offers to deep-link into the Tauri PVT panel (`neuroskill://validation/pvt`) or skip a week (snoozes for 6 days so the next reminder fires on cadence). +- **Two new commands**: `NeuroSkill: Open Validation Settings…` (deep links into the Tauri preferences pane) and `NeuroSkill: Check for Validation Prompt Now` (forces a single scheduler poll — useful for opt-in onboarding). +- **`DaemonClient.patch()`**: extension's daemon client gained a PATCH method so the "Stop these prompts" escape hatch can flip `enabled = false` on the persistent config. + +- **VS Code extension**: separate repo (`NeuroSkill-com/vscode-neuroskill`) as git submodule at `extensions/vscode/`. 50+ tracked event types across editing, navigation, debugging, git, AI, terminal, clipboard, and more. +- **Sidebar webview (Svelte 5)**: full brain dashboard in the VS Code activity bar with circular flow gauge, metrics strip, daily report, energy, struggle, optimal hours, workspace activity, environment, terminal impact, context cost, and dev loops. +- **Activity bar icon**: neural network SVG icon in the VS Code sidebar; NeuroSkill logo (PNG) in the webview header. +- **Brain status bar in VS Code**: polls daemon every 30s showing flow state, fatigue, streak, task type, and struggle score. Shows "offline" when daemon unreachable. Notifications for fatigue and struggle. +- **Dual-speed sidebar updates**: local data (files, terminals, layout) refreshes every 5s when sidebar is visible; brain endpoints every 30s. Pauses when hidden. +- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk via `globalStorageUri`. Auto-flushes on reconnect. Survives VS Code restarts. Cache count shown in status bar tooltip. +- **Workspace activity tracking**: per-file edits, lines added/removed, focus time, active file indicator. Grouped by workspace folder (project), sorted by activity. Top 10 files per project. +- **Terminal command tracking**: full command text, exit code, cwd, output streaming (via `execution.read()`), shell type detection (zsh/bash/fish/powershell/cmd/node/python), focus time per terminal, PID. Expandable command output in sidebar. +- **Terminal shell integration**: captures commands via `onDidStartTerminalShellExecution` / `onDidEndTerminalShellExecution` (VS Code 1.93+). CWD tracked via `onDidChangeTerminalShellIntegration`. +- **Zone tracking**: editor/terminal/panel time split with stacked color bar (blue/green/yellow). Layout chips showing tab count, editor groups, terminal count. +- **Auto EEG labeling**: every significant VS Code event auto-inserts a searchable label into EEG recordings with smart categorization (editing, debugging, git commits, AI assistance, meetings, errors, navigation, terminal commands, zone switches). +- **Inline label embedding**: auto-labels embedded immediately via fastembed for instant searchability (no idle reembed wait). +- **Command execution tracking**: 40+ VS Code commands tracked (go-to-definition, rename, find, format, fold, git, AI, debug, layout) with semantic categorization. +- **IntelliSense acceptance detection**: multi-char single-line insertions heuristically identified as autocomplete acceptances. +- **Clipboard tracking**: clipboard content changes polled every 5s with debounce. +- **Layout snapshots**: periodic (60s) capture of editor groups, visible editors, open tabs, terminal count sent to daemon. +- **Zone switch events**: editor/terminal/panel transitions sent to daemon with EEG focus snapshot. +- **File system watcher**: selective watching of package.json, Cargo.toml, go.mod, .git/HEAD for external change detection. +- **Environment context**: one-time capture of appHost, remoteName, shell, uiKind, language. +- **Info toggles**: every sidebar card has a `?` button explaining how metrics are calculated (flow score formula, struggle signals, energy bar, optimal hours, dev loops, terminal impact, context cost). +- **Open NeuroSkill button**: launches native app from sidebar (cross-platform: `open -a` macOS, `start` Windows, `xdg-open` Linux). Also in command palette. +- **Command palette → sidebar**: `Show Brain Status`, `Today's Report`, `Am I Stuck?`, `Best Time to Code` now open sidebar and scroll to relevant section instead of showing toast notifications. + +- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable insights in one call. +- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — "your tests fail 45% more when focus is low." +- **Hourly productivity**: churn + undo rate + avg focus by hour of day — "you write 3x more bugs after 2pm." +- **Context switch recovery**: focus level at editor/terminal/panel transitions — "switching to terminal costs 4 focus points." +- **AI tool impact**: Claude/Pi focus delta vs baseline — "Claude conversations drop focus by 8 points." +- **Focus by language**: avg EEG focus + undo rate per programming language — "best focus in Rust, worst in CSS." +- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day. +- **Tool focus impact**: avg focus when using each command category (docker, git, deploy, etc.). + +- **Widget accessibility and localization**. + +- **Analysis widgets (Optimal Hours, Daily Report, Weekly Trend)**. + +- **Biometric widgets (Heart Rate, Cognitive Load, EEG Band Power, Sleep)**. + +- **Brain Dashboard widget (medium)**. + +- **Calendar Mind State widget (large)**. + +- **Widget deep links (neuroskill:// URL scheme)**. + +- **Widget development infrastructure**. + +- **Core desktop widgets (Focus, Streak, Session, Break Timer)**. + +- **Interactive widget buttons (macOS 14+)**. + +- **Widget offline data caching**. + +- **Widget timeline reload on state changes**. + +- **zuna-rs 0.1.4**: updated from 0.1.3 with native MLX backend for EEG embedding inference. +- **`embed-zuna-mlx` feature**: new `ZunaMlxState` struct with `ZunaEncoder`. Adds `load_zuna_mlx()` and `encode_zuna_mlx()` functions matching the existing GPU/CPU variants. +- **Load priority**: when user selects Auto or MLX, encoder loading tries MLX first, then GPU f16, then GPU f32, then CPU. + +### Performance + +- **MLX vs GPU benchmarks** (Mac mini, Apple Silicon): + +| Dataset | Points | GPU (wgpu) | MLX | Speedup | +|---|---|---|---|---| +| Small | 200 | 120.9 s | 2.3 s | **51x** | +| Medium | 1,000 | 136.6 s | 7.1 s | **19x** | +| Large | 5,000 | 152.8 s | 23.8 s | **6.4x** | + +- **`open_readonly` for read-only handlers**: added `ActivityStore::open_readonly()` that skips 15+ ALTER TABLE migrations and opens in read-only mode. Switched 38+ HTTP handlers and 3 background tasks from `open` to `open_readonly`. +- **`PRAGMA busy_timeout=5000`**: added to `init_wal_pragmas` and `util::open_readonly` so all database connections (activity, labels, screenshots, EEG embeddings) wait up to 5 s on lock contention instead of failing immediately with SQLITE_BUSY. +- **Composite indexes**: added `(seen_at, file_path)` and `(seen_at, project)` indexes on `file_interactions` for queries that filter by time range and group by path or project. +- **Lock consolidation**: `productivity_score` reduced from 3 separate mutex acquisitions to 1 via internal `_q` query helpers. `weekly_digest` reduced from 12 to 4 locks. Introduced `daily_summary_q`, `context_switch_rate_q`, `get_focus_sessions_in_range_q` static methods that take `&Connection` directly. +- **`PRAGMA optimize` after pruning**: runs after hourly retention pruning to keep query planner statistics fresh. +- **Batch label lookup in interactive search**: replaced 15 per-epoch `get_labels_near` calls (each opening a new DB connection) with a single `get_labels_near_batch` call per text label, then in-memory filtering. Reduces search DB round-trips from ~28 to ~16. + +### Bugfixes + +- **Daemon-side event storage**: `git_commit`/`push`/`pull`/`checkout`/`stage`/`unstage`/`stash` events were received but never written to `build_events` (used only for EEG labels). Now persisted, with AI-assisted commits also recorded as `ai_events` (type `"ai_commit"`) and labelled `"git commit (AI)"`. +- **Completion-accepted events were dropped**: previously ignored by the daemon. Now stored as `ai_events` (type `"suggestion_accepted"`) and as edit chunks so they show up in code metrics and AI usage analytics. + +## Impact on analysis + +Brain analysis endpoints can now: +- Count human vs AI commits (`/brain/developer-insights`) +- Track AI suggestion acceptance rates (`/brain/ai-usage`) +- Include git activity in the activity timeline +- Weight human-authored code differently from AI output in focus/productivity scores + +## Files + +- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Event handler updates + +- **Schema migration**: ALTER TABLE for existing databases missing columns added across releases. Runs before DDL to handle all legacy schemas. +- **Retention pruning**: meetings, clipboard, and secondary_windows pruned alongside file interactions during hourly maintenance. + +- **Dismiss stale Dependabot alerts**: crossbeam-deque, crossbeam-utils, crossbeam-queue, memoffset, and glib alerts dismissed (already at patched versions or fixed in fork). + +- **CORS allow-methods now includes `PATCH`**: `crates/skill-daemon/src/main.rs` was advertising only `GET, POST, PUT, DELETE, OPTIONS`, so the browser preflight blocked `PATCH /v1/validation/config` from the Tauri webview. Added `Method::PATCH` to the list. + +- **Equal padding on screenshot edges**: viewport width was 360px while the body was 320px wide, leaving an asymmetric 40px right gutter. Matched viewport to body width so left and right gutters are now identical. + +- **Deadlock in daily-report endpoint**: `daily_brain_report` held the database mutex while calling `productivity_score`, which re-acquires the same mutex. Scoped the lock to drop before the nested call. +- **Deadlock in struggle-predict endpoint**: same pattern in `predict_struggle` — held mutex while calling `get_recent_files`. +- **`overall_focus` returned `-0.0`**: always wrapped in `Some` even with no EEG data. Now returns `null` when no focus samples exist. +- **`best_period` arbitrary without EEG**: picked the first SQL result when all `avg_focus` were `None`. Now returns empty string when no EEG data to compare. +- **`weekly_avg_deep_mins` returned `-0.0`**: divided by hardcoded 7 regardless of actual days. Now divides by actual day count, returns `0.0` when empty. +- **Notification text ugly without EEG**: daily report notification showed "Best: . Score: 25. Focus: 0." — now conditionally includes only available fields. +- **Brain endpoints return HTTP 200 on errors**: all 18 brain handlers silently returned `null` with 200 OK when the activity store was offline or a query failed. Refactored to use `run_query` helper that returns 503 (db_unavailable) or 500 (task_error) with structured `ApiError` JSON. + +- **Worker thread panic recovery with auto-restart**: all 4 activity worker threads (poller, input monitor, file watcher, clipboard monitor) now wrapped in `catch_unwind` via `spawn_resilient`. On panic, the worker logs the error and restarts after 5 s with freshly cloned `AppState` and `Arc`. +- **osascript timeout**: all `osascript` calls (active window poll, secondary windows, clipboard monitor) now use a 3-second timeout via `run_osascript` helper. Previously, a hung app could block the poller thread indefinitely, silently killing all activity tracking. +- **Daily report catch-up on late startup**: if the daemon starts after 18:00 local, the daily brain report fires on the first tick instead of waiting up to 1 hour for the hourly check. +- **WAL checkpoint on shutdown**: daemon now runs `PRAGMA optimize` on the activity database during graceful shutdown, ensuring the WAL is checkpointed and the database is clean for next start. +- **Missing WAL pragmas on EEG embedding store**: `day_store.rs` opened connections without `init_wal_pragmas`, missing both WAL mode and `busy_timeout`. +- **Retention pruning for new tables**: added `prune_terminal_commands`, `prune_ai_events`, `prune_zone_switches`, `prune_layout_snapshots` — wired into the hourly maintenance cycle so these tables don't grow unbounded. +- **TOCTOU race in calibration settings**: concurrent profile CRUD could lose writes. Added `modify_settings_blocking()` helper that runs under a global mutex on tokio's blocking thread pool — serializes read-modify-write without blocking the async runtime. + +- **Duration-averaged EEG**: `FileSnapshot` now samples EEG focus/mood every 5 s (at each edit-chunk tick) and writes the average to `file_interactions` at finalize, replacing the single snapshot captured at file-switch time. Falls back to the initial snapshot if no samples were collected (`COALESCE`). +- **EEG mood/focus sample count mismatch**: both `FileSnapshot` and `build_focus_sessions` used a single counter for focus and mood samples, inflating mood averages when they arrived independently. Split into separate `focus_count` and `mood_count`. +- **EEG ghost data after disconnect**: `latest_bands` was not cleared when an EEG device disconnected, causing stale focus/mood values to persist in reports and the activity pipeline. Now cleared on all 3 disconnect paths (idle timeout, stream end, explicit disconnect). + +- **InteractiveGraph3D event listener leaks**: `pointerdown` and `dblclick` listeners on the WebGL canvas were never removed in `onDestroy`. Extracted into named refs with cleanup. +- **UmapViewer3D event listener leaks**: 4 canvas event listeners (pointermove, pointerleave, pointerdown, pointerup) added as anonymous functions were never removed on unmount. Extracted into named module-level refs with removal in `onDestroy`. +- **ActivityTab brain polling leak**: `startBrainPolling()` was called on mount but `stopBrainPolling()` was never called on destroy, leaving a 30-second interval running after leaving the tab. +- **Brain polling stopped by peer component**: `stopBrainPolling()` unconditionally killed the interval even when other components still needed it. Added reference counting so polling only stops when the last consumer unmounts. +- **Memory leak in terminal_command_end labeling**: `Box::leak` was used to format non-zero exit codes in EEG auto-labeling, permanently leaking memory for every failed command. Replaced with owned `String`. +- **Screenshot encode failures untracked**: `encode_webp` failures (disk full, I/O error) silently continued without incrementing the `capture_errors` counter. Now counted so metrics reflect actual failures. +- **401 auto-retry**: daemon HTTP client now retries once with a fresh token on 401 (stale token after daemon restart) instead of failing immediately. + +- **Path traversal in `delete_session`**: `csv_path` parameter was passed unsanitized to `fs::remove_file`. Now validates the path is within `skill_dir` via canonicalize + starts_with. +- **Timing-vulnerable token comparison**: default bearer token checks in auth middleware used `==` (variable-time). Switched to `constant_time_eq` for both in-memory and on-disk token paths. +- **Unbounded CSV memory in EXG loader**: loading a CSV with millions of rows would allocate unlimited RAM. Added a 4M row cap (~4.3 hours at 256 Hz). + +- **Timezone-wrong period classification**: `daily_brain_report`, `hourly_edit_heatmap`, and `optimal_hours` used `seen_at % 86400` (UTC hour) instead of local hour. Daily report now uses `seen_at - day_start`; heatmap and optimal-hours accept a timezone offset computed server-side. +- **UTC midnight in all clients**: search page, CLI (`cmdActivity`, `cmdBrain`), VS Code extension, and Swift widget all computed `day_start` as UTC midnight instead of local midnight. Fixed to use `Date.setHours(0,0,0,0)` (JS/TS) and `Calendar.startOfDay` (Swift). +- **Widget empty daily-report body**: `DaemonClient.fetchDailyReport()` sent an empty POST body; the endpoint requires `dayStart`. Now sends local midnight. +- **Widget UTC sleep/calendar endpoints**: `fetchSleep()` and `fetchCalendarEvents()` used `% 86400` UTC approximations. Now use `Calendar.current` for correct local time boundaries. + +### Refactor + +- **Shared `DaemonClient` for VS Code extension**: extracted the repeated `fetch` + auth-token + port-discovery + 3 s timeout pattern into one class. All features now use `client.post(path, body)`. Returns `null` on failure (never throws), with `setToken()` for refresh on reconnect. + +## Before + +Every component (brain.ts, sidebar.ts, extension.ts) independently constructed fetch calls: +```typescript +const port = await discoverDaemonPort(config); +const base = `http://${config.daemonHost}:${port}/v1`; +const headers = { "Content-Type": "application/json" }; +if (token) headers["Authorization"] = `Bearer ${token}`; +const resp = await fetch(`${base}${path}`, { method: "POST", headers, body, signal: AbortSignal.timeout(3000) }); +``` + +## After + +```typescript +const client = new DaemonClient(config, token); +const result = await client.post("/brain/flow-state", { windowSecs: 300 }); +``` + +## Benefits + +- Single place to update auth, timeout, port discovery +- All 8 new features use the shared client +- `setToken()` method for token refresh on reconnect +- Returns `null` on any failure (never throws) — all features handle gracefully + +## Files + +- `src/daemon-client.ts` — DaemonClient class (new) + +- **Sidebar disclaimer test now accepts either localiser**: existing `vscode-sidebar-disclaimer.test.ts` updated so the `vscode.l10n.t("sidebar.disclaimer")` ↔ `tr("sidebar.disclaimer")` migration doesn't break the assertion. Both look up the same key. + +- **Conversations table + FTS5**: full-text search on all AI conversation messages (user/assistant/tool). Three search modes: FTS, fuzzy, structured. +- **EEG timeseries table**: periodic JSON snapshots of all brain metrics. Extensible — new metrics without schema changes. Join-at-query-time correlation with any event. +- **Embeddings store**: generic, multi-model. User prompts embedded via fastembed (local, no API credits). Can re-embed with different models. +- **Code context HNSW index**: separate from label index for code-specific semantic search. +- **Binary extraction**: terminal commands store raw binary name + args separately. Lazy categorization — 284 CLI tools recognized, rerun anytime. +- **EEG attachment**: live focus/mood from `latest_bands` injected at event storage time. +- **EEG timeseries worker**: writes full band powers to `eeg_timeseries` every 5s during recording. + +### Build + +- **CI for the VS Code extension** (`extensions/vscode/.github/workflows/`): two workflows live in the submodule repo (`vscode-neuroskill`). + - **`ci.yml`** — runs on every PR and `main` push. `npm ci` → `tsc` build → `vsce package` → uploads the `.vsix` as a 14-day artifact. No secrets, no publish. + - **`release.yml`** — fires two ways. *Tag push* (`git push --tags` after `npm version patch`) verifies the tag matches `package.json` and publishes. *Manual dispatch* with a `patch | minor | major | x.y.z` input bumps `package.json`, commits, tags, pushes, then publishes. Both paths run `npx @vscode/vsce publish` (uses `VSCE_PAT`) and `npx ovsx publish` (uses `OVSX_PAT`), then create a GitHub release with the `.vsix` attached and the latest `## ` block from `CHANGELOG.md` as release notes. +- **Skip-on-missing-secret**: if either `VSCE_PAT` or `OVSX_PAT` is unset, the corresponding publish step emits a `::warning::` and is skipped — letting you wire up Open VSX later without breaking the workflow. +- **Releasing section in the extension README**: documents the secret setup (Azure DevOps PAT, Open VSX token), the two release flows, and the one-time namespace claims (Marketplace publisher registration + `ovsx create-namespace`). + +- **macOS: link libusb statically into `skill-daemon`**: vendored libusb via `rusb`'s `vendored` feature in `crates/skill-devices`. The `antneuro` USB driver pulls in `rusb` → `libusb1-sys`, which by default uses `pkg-config` to find a system libusb-1.0.dylib. On the macOS-26 release runner that resolved to Homebrew's `/opt/homebrew/opt/libusb/lib/libusb-1.0.0.dylib`, so end users without Homebrew hit a launch-time `dyld: Library not loaded` error. Cargo feature unification now flips `libusb1-sys/vendored`, which compiles libusb from source and links `libusb-vendored.a` into the binary — verified by 105 `_libusb_*` text symbols present in the release binary and zero libusb references in `otool -L`. + +### CLI + +- **`neuroskill activity` expanded**: 19 subcommands — bands, window, input, windows, files, top-files, top-projects, languages, sessions, meetings, clipboard, builds, heatmap, switches, summary, score, digest, stale, timeline. +- **`neuroskill brain` added**: 14 subcommands — flow, load, load-files, recovery, optimal, fatigue, struggle, report, breaks, streak, task, stuck, interruptions, correlation. +- **`neuroskill vscode` added**: auto-build and install VS Code/VSCodium/Cursor extension from source on macOS, Linux, and Windows. + +### UI + +- **EEG focus timeline heatmap**: collapsible "Focus Timeline" sidebar section renders a ~280×36 px SVG sparkline of today's EEG focus (`/brain/eeg-range`, max 120 points), color-graded green/yellow/red, with hour labels and file-name annotations at peaks/valleys (merged from `/activity/timeline`). Toggle via `neuroskill.eegHeatmap`. + +## What you see + +In the NeuroSkill sidebar, a collapsible "Focus Timeline" section shows: + +- A ~280px wide, ~36px tall SVG sparkline +- Color gradient: green (>70 focus), yellow (40-70), red (<40) +- Hour labels along the bottom (0:00, 3:00, 6:00, ...) +- File names annotated at focus peaks and valleys + +## Data sources + +| Data | API Endpoint | +|------|-------------| +| EEG time-series | `/brain/eeg-range` (today, max 120 points) | +| File context | `/activity/timeline` (today, last 200 events) | + +The heatmap merges EEG data points with the closest timeline events to show which files correspond to focus peaks and dips. + +## Settings + +`neuroskill.eegHeatmap` (default: `true`) — Show/hide the heatmap in the sidebar. + +## Files + +- `src/sidebar.ts` — `_fetchHeatmap()` and `_renderHeatmap()` methods + +- **Brain status bar**: persistent bottom bar in Tauri app showing flow state, fatigue, streak, edit velocity, and focus score. +- **Brain dashboard card**: flow/fatigue/streak + focus progress bar on the EEG dashboard alongside existing BrainStateScores. +- **Brain indicators in ActivityTab**: flow state card (green), fatigue alert card (amber), and streak card (violet) at top. + +- **Tauri Brain Insights section** in Activity tab: test failure rates by focus level, focus by language bars, AI impact delta, tool focus grid, hourly productivity chart. +- **Tauri EEG focus inline**: terminal commands and conversation messages show EEG focus score (green/yellow/red) at the moment they occurred. +- **VS Code Brain Insights card**: test failure by focus chips, focus by language bars, tool impact chips. Only shown when EEG data is available. +- **VS Code Struggle card**: hidden when no EEG recording (focus is EEG-only, not activity-based). +- **VS Code Flow gauge label**: shows "focus" with EEG, "activity" without — no false claim of brain measurement. + +- **Tauri AI Conversations section** in Activity tab: timestamped message thread with role icons (user/assistant/tool), app name, EEG focus score, scrollable. +- **VS Code Conversations card**: timestamped messages with role icons, app badge, collapsible. +- **VS Code AI Usage card**: Copilot/Codeium acceptance rate bar, suggestion count, source breakdown chips. + +- **Search mode dropdown**: replaced 4-tab bar with a styled dropdown supporting 7 search modes — Interactive, EEG, Text, Images, Code, Meetings, Brain. +- **Diamond EEG nodes**: EEG epochs rendered as faceted octahedrons (diamonds) in the 3D search graph. Meeting nodes rendered as tetrahedrons (pyramids). Visual distinction by data type. +- **New search modes**: Code (file activity + brain state), Meetings (meeting events + recovery), Brain (flow/fatigue/struggle insights). +- **Terminal commands in Cmd-K**: added 4 terminal commands to the command palette — Recent Terminal Commands, Terminal Focus Impact, Context Switch Cost, Dev Loops. Translated labels in all 9 locales. + +- **Configurable alerts**: `neuroskill.alerts.focusLow` (warn when EEG focus drops below threshold), `neuroskill.alerts.continuousWorkMins` (warn after N minutes continuous work). +- **Daily digest notification**: at configurable time (default 17:30), shows productivity score + stats. "Full Report" button opens detail panel. +- **Full report panel**: `Cmd+Shift+R` opens full-width webview with same data as sidebar. +- **Keyboard shortcuts**: `Cmd+Shift+N` (toggle sidebar), `Cmd+Shift+R` (full report), `Cmd+Shift+Alt+N` (reconnect). +- **Open NeuroSkill button**: launches native app (cross-platform). +- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk. Auto-flushes on reconnect. + +- **Spacing pass on the validation tab and modals**: every card uses `flex flex-col gap-5 p-6` instead of the old `space-y-4` (which provided no padding override). Conditional sub-settings under a channel toggle are now indented under a left border (`ml-1 border-l-2 border-border pl-5`) so the parent/child relationship reads visually. Modals padded to `p-8` with `gap-3` button rows. TLX sliders gained `mt-1` lift off the description and `uppercase tracking-wide` low/high legend labels. + +- **Design tokens** (`tokens.css`): 30+ CSS custom properties — palette, surfaces, backgrounds, borders, semantic, typography. Zero inline rgba() in sidebar. +- **7 reusable Svelte components**: Card, MetricRow, Chevron, ProgressBar, Gauge, Badge, Callout. +- **Timestamp compliance**: 25 tests verifying UTC in data layer, local conversion only in UI. + +- **Theme-aware sidebar screenshots in the README**: every screenshot now ships in two variants (`*-dark.png`, `*-light.png`) and is embedded via a `` element with `prefers-color-scheme` sources. GitHub serves the variant matching the reader's OS theme; the marketplace gets the dark fallback. Six states captured: in-flow, stuck, fatigued, low-focus / off-peak, daemon-disconnected, status-bar strip. +- **Sidebar mock library + Playwright generator**: HTML stubs in `extensions/vscode/media/preview/` render the sidebar webview offline (no daemon required). A shared `_styles.css` drives both themes via a `:root.light` override; `npm run screenshots` (Playwright) toggles `` between captures and writes 12 PNGs to `media/screenshots/`. +- **Marketplace README pipeline**: `scripts/build-marketplace-readme.mjs` rewrites every relative `media/...` path (including ``, which `vsce` doesn't touch) to absolute GitHub raw URLs at package time. The source `README.md` keeps relative paths for GitHub + offline viewing; the generated `.marketplace.readme.md` is what `vsce package --readme-path` ships. + +- **Sidebar webview now renders correctly in light themes** (`extensions/vscode/src/sidebar.ts`). Three theme-fragile spots fixed: + - `.metric-card` background switched from `--vscode-sideBar-background` (which is the same colour as the surrounding sidebar — cards collapsed into a faint border in light mode) to `--vscode-editorWidget-background`, with `--vscode-input-background` and a translucent grey as cascading fallbacks. + - `.ai-bar` track switched from `--vscode-panel-border` to `--vscode-progressBar-background` so the empty bar is visible against a white background. + - Row-divider opacity bumped from 0.08 to 0.18; added `:last-child { border-bottom: none }` on commit and AI-metric rows so the last row doesn't double up against the disclaimer footer. +- **State colours stay hard-coded** — red ring for stuck, green for flow, amber for warning. They're semantic, not chrome, and they read fine on both themes. + +### Server + +- **`POST /v1/activity/shell-command`**: receives commands from shell hooks with command text, cwd, shell type, exit code. +- **`POST /v1/brain/terminal-commands`**: query recent commands with exit codes, durations, categories, EEG correlation. +- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category. +- **`POST /v1/brain/binary-stats`**: usage frequency per binary for discovering uncategorized tools. +- **`POST /v1/brain/recategorize-commands`**: rerun categorization rules on all historical commands. +- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end. +- **Zone switch events**: editor/terminal/panel transitions stored with EEG focus snapshot. `POST /v1/brain/context-cost` returns focus at each transition type. +- **Layout snapshots**: periodic (60s) tab/group/terminal counts from VS Code. + +- **`/v1/validation/*` HTTP endpoints** (axum): `GET /config`, `PATCH /config` (recursive JSON merge for partial updates), `POST /snooze`, `POST /disable-today`, `GET /should-prompt` (returns the daemon's prompt decision; logs the fire so the rate-limiter sees it), `POST /kss`, `POST /tlx`, `POST /pvt`, `POST /close-prompt`, `GET /results`, `GET /fatigue-index` (live `(α+θ)/β` from the latest band snapshot). +- **`AppState.validation_runtime`** (`crates/skill-daemon-state/src/state.rs`): new `Arc>` field holding ephemeral snooze/disable-today state. Resets on daemon restart by design — a crash loop shouldn't keep prompts suppressed forever. + +- **`terminal_commands` table**: shell command text, cwd, exit code, duration, auto-categorized (50+ patterns: build/test/run/git/docker/deploy/install/navigate/debug/network/other), EEG focus at start and end for delta calculation. +- **`dev_loops` table**: edit→build/test cycle tracking with iteration count, pass/fail rate, avg cycle time, focus trend (rising/falling/stable). +- **`zone_switches` table**: editor/terminal/panel transitions with EEG focus snapshot at moment of switch. +- **`layout_snapshots` table**: periodic tab/group/terminal counts from VS Code. +- **Event handler**: new match arms for `terminal_command_start`, `terminal_command_end`, `zone_switch`, `layout_snapshot` in `activity_vscode_events_impl()`. +- **Command categorizer**: `categorize_command()` with 50+ patterns across build, test, run, git, docker, deploy, install, navigate, debug, network. +- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category — shows how builds, tests, git, docker affect brain state. +- **`POST /v1/brain/context-cost`**: focus level at each zone transition type with switch counts. +- **`POST /v1/brain/dev-loops`**: edit-build-test cycle detection with iterations, pass rate, cycle time, focus trend. +- **`POST /v1/brain/terminal-commands`**: recent commands with exit codes, durations, categories, EEG correlation. +- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages". +- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl→infrastructure (0.8), deploy→deploying (0.85), git dominating→git_management (0.7), test→testing (0.85), debug→debugging (0.9). +- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end, `"switched to terminal"` on zone changes. +- **AI events table**: `ai_events` SQLite table tracking suggestion shown/accepted/rejected and chat sessions with source attribution. +- **OS-wide shell hooks**: `scripts/shell-hooks/` with preexec/precmd hooks for zsh, bash, fish, PowerShell. Sends every command to daemon via background curl. No delay to prompt. +- **Shell hook daemon endpoints**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to `~/.skill/shell-hooks/` and appends to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check. +- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). +- **`neuroskill brain` new subactions**: `terminal-impact`, `context-cost`, `dev-loops`. +- **`neuroskill activity` new subaction**: `terminal-commands`. +- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons, health indicators (green/yellow/red), recent commands preview, "How it works" documentation. +- **`neuroskill vscode` CLI**: auto-install extension to VS Code, VSCodium, or Cursor on macOS, Linux, and Windows. +- **Meeting nodes in search graph**: meetings appear as amber nodes in the interactive 3D search graph linked by `meeting_prox` edges. + +### i18n + +- Activity dashboard, clipboard, file history, session replay, weekly report, brain status, timeline, and code-brain correlation translations for all 9 locales. + +- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +- Added `umapSettings.backend`, `umapSettings.backendDesc`, `umapSettings.precision`, `umapSettings.precisionDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +- Added `dnd.grayscale` and `dnd.grayscaleDesc` translations to all 9 locales. + +- Search mode labels (Code, Meetings, Brain) translated in all 9 locales. +- Terminal command palette entries translated in all 9 locales. + +- **New `validation` namespace** in all 9 languages (`src/lib/i18n/{de,en,es,fr,he,ja,ko,uk,zh}/validation.ts`): ~95 keys in English (full coverage), ~55 in each other language for the user-visible strings (long-tail descriptions fall back via the runtime `t()` chain). `index.ts` barrel exports updated for every language. + +- **22 new `validation.*` keys per bundle** in all 9 languages (`bundle.l10n.{de,en,es,fr,he,ja,ko,uk,zh-cn}.json`): KSS prompt + 9 score labels with translated Karolinska wording, TLX/PVT prompts, all four escape-hatch labels, the disabled-permanently acknowledgement. +- **Two new `cmd.*` keys** for the validation commands in `package.nls.json`. + +- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +### Docs + +- VS Code extension design plan at `docs/vscode-extension.md`. +- `neuroskill-activity` skill documentation with 18 activity + 24 brain subcommands, terminal integration, shell hook reference, command categorization table. +- Updated `neuroskill-dnd` skill with grayscale mode. +- Updated `neuroskill/README.md` with terminal, brain awareness, and VS Code extension features. +- Updated `skills/SKILL.md` index with terminal tracking skill reference. + +- **VS Code extension README rewritten for skeptical developers**: scientific framing throughout — every derived metric (focus score, flow detector, deep-work minute, struggle predictor, optimal hours, fatigue) now ships with its formula, hyperparameters, and a "what can go wrong" line. Added an explicit *What this is, and isn't* section, a *Validation status* table tracking each metric's evidence level (production / pilot / heuristic / descriptive-only), and a *Validation roadmap* describing the planned KSS / NASA-TLX / PVT / EEG-fatigue calibration channels. Replaced the marketing pain-points table with a per-API signal taxonomy and an architecture paragraph. +- **References section with verified citations**: nine entries with DOIs cross-checked via CrossRef and `doi.org` redirects — Csikszentmihalyi 1990 (flow), Klimesch 1999 (α/θ oscillations), Lubar 1991 (θ/β attention ratio), Barry et al. 2005 (caffeine confound), Newport 2016 (deep work), Roenneberg et al. 2003 (chronotypes), Hart & Staveland 1988 + Hart 2006 (NASA-TLX), Jap et al. 2009 (EEG fatigue index). Each reference linked back to the metric it grounds. +- **Repository metadata pointed at the right repo**: discovered `extensions/vscode/` is a git submodule of `vscode-neuroskill`, not a subdirectory of the monorepo. Reverted `repository.url`, `bugs.url`, `homepage`, `qna` to the submodule's actual repo so the marketplace links resolve. + +### Dependencies + +- **burn-mlx**: added `burn-mlx` from git (`eidola-ai/burn-mlx`, branch `burn-0-20`) as optional dependency in `skill-router` and `skill-daemon`. Workspace `[patch.crates-io]` redirects all `burn-mlx` references to the git version (crates.io 0.1.2 targets burn 0.16; project uses burn 0.20). +- **macOS deployment target**: bumped from 10.15 to 14.0 in `.cargo/config.toml` for Metal 3.0 `simdgroup_matrix` intrinsics required by MLX. Still satisfies llama.cpp's `std::filesystem` (available since 10.15). +- **half 2.4**: added to `skill-router` for GPU f16 precision backend type (`CubeBackend`). + +- **Fix rand 0.7.3 vulnerability**: vendored `phf_generator 0.8.0` with rand bumped from 0.7 to 0.8, eliminating the vulnerable transitive dependency. +- **Remove atty**: migrated `iroh_test_client` from `structopt` (clap v2) to `clap v4` derive, dropping the unmaintained `atty` crate. +- **Update llama-cpp-4 to 0.2.50**: upstream llama.cpp fixes including `common_*` symbol renames. +- **Update kittentts to 0.4.1**: TTS engine update. +- **Add grayscale 0.0.1**: macOS grayscale display control for DND mode. diff --git a/changes/unreleased/01-human-vs-ai-tracking.md b/changes/unreleased/01-human-vs-ai-tracking.md deleted file mode 100644 index 6c856913..00000000 --- a/changes/unreleased/01-human-vs-ai-tracking.md +++ /dev/null @@ -1,43 +0,0 @@ -### Features - -- **Human vs AI activity tracking**: every edit and commit is now classified as `source: "human"` or `source: "ai"` in real-time. `AIActivityTracker` watches VSCode commands (Copilot/Codeium completions, inline chat, AI-generated commit messages) to tag subsequent edits/commits, exposes `getAIRatioForFile()` (rolling 5-minute ratio) for CodeLens + sidebar, and forwards the `source` field to the daemon for `build_events` / `ai_events` storage. - -## How it works - -The `AIActivityTracker` monitors VSCode command execution to detect AI tool usage: - -- **Inline completions** (Copilot, Codeium) — edits within 5 seconds after an AI command are tagged `source: "ai"` -- **Inline chat** (`inlineChat.start`, `copilot.interactiveEditor.*`) — subsequent edits in the same file are tagged AI -- **Commit messages** — `github.copilot.git.generateCommitMessage` marks the next commit as AI-assisted -- **Everything else** — classified as `source: "human"` - -## What's tracked - -| Signal | Classification | -|--------|---------------| -| Manual typing | `human` | -| Copilot inline suggestion accepted | `ai` | -| Copilot inline chat edits | `ai` | -| Paste from external source | `human` | -| AI-generated commit message | `ai` | -| Manually typed commit message | `human` | - -## Per-file AI ratio - -`AIActivityTracker.getAIRatioForFile(path)` returns a rolling 5-minute ratio (0.0 = all human, 1.0 = all AI) used by: -- CodeLens annotations (shows "AI-Assisted" vs focus score) -- Sidebar (Human/AI percentage display) -- Brain status command (Human/AI split) - -## Daemon integration - -The `source` field is sent on every `edit` and `git_commit` event to the daemon. The daemon stores: -- AI commits as `"git commit (ai-assisted)"` in `build_events` -- AI commits also as `ai_events` for analytics weighting -- Completion acceptances as `ai_events` with type `"suggestion_accepted"` - -## Files - -- `src/ai-tracker.ts` — Core tracker (new) -- `src/events.ts` — Wired to classify edits and commits -- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Daemon-side storage diff --git a/changes/unreleased/02-focus-code-review.md b/changes/unreleased/02-focus-code-review.md deleted file mode 100644 index eafe1068..00000000 --- a/changes/unreleased/02-focus-code-review.md +++ /dev/null @@ -1,31 +0,0 @@ -### Features - -- **Focus-aware code review CodeLens**: annotations at the top of each file show the developer's focus level when the file was last edited (`⚠ Low Focus (42)`, `ℹ Focus: 65/100`, `🤖 AI-Assisted (85%)`, or none for high focus). `NeuroSkill: Show Files Needing Review` command lists low-focus human-authored files. Toggle via `neuroskill.focusCodeLens`. - -## What you see - -- `⚠ Low Focus (42) — Review Recommended` — File was edited during low focus. Click to see all files needing review. -- `ℹ Focus: 65/100` — Moderate focus, informational only. -- `🤖 AI-Assisted (85%)` — Most edits were AI-generated, focus score not applicable. -- No annotation — High focus (>70) or no data yet. - -## Commands - -**NeuroSkill: Show Files Needing Review** (`Cmd+Shift+P`) -- Shows a QuickPick list of files edited during low focus (<50) that were mostly human-authored -- Sorted by focus score (lowest first) -- Select a file to open it - -## How it works - -- `FocusCodeLensProvider` queries `/brain/cognitive-load` (grouped by file) every 30 seconds -- Combines focus data with `AIActivityTracker.getAIRatioForFile()` to distinguish human vs AI code -- Files with high AI ratio (>70%) show AI label instead of focus score — AI code doesn't reflect human cognitive state - -## Settings - -`neuroskill.focusCodeLens` (default: `true`) — Toggle CodeLens annotations on/off. - -## Files - -- `src/codelens-provider.ts` — CodeLens provider (new) diff --git a/changes/unreleased/03-flow-shield.md b/changes/unreleased/03-flow-shield.md deleted file mode 100644 index 54b5c842..00000000 --- a/changes/unreleased/03-flow-shield.md +++ /dev/null @@ -1,28 +0,0 @@ -### Features - -- **Smart Interruption Shield**: when `/brain/flow-state` reports `in_flow: true`, VS Code's Do Not Disturb mode auto-enables and the status bar shows `$(shield) In Flow 12m`. `NeuroSkill: Toggle Flow Shield` cycles Auto / Forced-on / Forced-off. Toggle via `neuroskill.flowShield`. - -## How it works - -- When `/brain/flow-state` reports `in_flow: true`, the Flow Shield activates -- Enables VSCode's Do Not Disturb mode (VSCode 1.90+) -- Shows `$(shield) In Flow 12m` in the status bar with elapsed time -- When flow state ends, DND is automatically disabled - -## Manual override - -**NeuroSkill: Toggle Flow Shield** (`Cmd+Shift+P`) - -Cycles through three modes: -1. **Auto** (default) — activates/deactivates based on EEG flow detection -2. **Forced on** — always active regardless of flow state -3. **Forced off** — never active - -## Settings - -`neuroskill.flowShield` (default: `true`) — Enable/disable the flow shield feature. - -## Files - -- `src/flow-shield.ts` — Flow shield implementation (new) -- `src/brain.ts` — Calls `flowShield.update()` every 30s diff --git a/changes/unreleased/04-adaptive-break-coach.md b/changes/unreleased/04-adaptive-break-coach.md deleted file mode 100644 index bda490e5..00000000 --- a/changes/unreleased/04-adaptive-break-coach.md +++ /dev/null @@ -1,33 +0,0 @@ -### Features - -- **Adaptive Break Coach**: personalized break timing based on the developer's actual EEG focus cycle (via `/brain/break-timing`), not generic Pomodoro. Status bar countdown `$(clock) Break in 8m`, max one notification per cycle, auto-resets when fatigue indicates idleness. `NeuroSkill: Take a Break` resets the timer manually. Toggle via `neuroskill.breakCoach`. - -## How it works - -- Queries `/brain/break-timing` to learn the developer's natural focus cycle length -- Shows a countdown in the status bar: `$(clock) Break in 8m` -- When the predicted focus drop is imminent (<5 min), the countdown turns visible -- When the cycle ends, shows `$(clock) Break time` and optionally notifies - -## Notifications - -- Max one notification per focus cycle -- Message: "You've been focused for 47m. Your natural cycle is 42m — take a break?" -- Buttons: "Take Break" (resets timer) or "Dismiss" - -## Timer sync - -The break coach automatically syncs with the daemon's fatigue data. If `continuous_work_mins` drops below 5 (indicating the user was idle), the session timer resets — no false break suggestions after returning from lunch. - -## Commands - -**NeuroSkill: Take a Break** (`Cmd+Shift+P`) — Manually acknowledge a break and reset the timer. - -## Settings - -`neuroskill.breakCoach` (default: `true`) — Enable/disable break coaching. - -## Files - -- `src/break-coach.ts` — Break coach implementation (new) -- `src/brain.ts` — Calls `breakCoach.refresh()` and `resetSessionIfIdle()` every 30s diff --git a/changes/unreleased/05-struggle-ai-bridge.md b/changes/unreleased/05-struggle-ai-bridge.md deleted file mode 100644 index 17979881..00000000 --- a/changes/unreleased/05-struggle-ai-bridge.md +++ /dev/null @@ -1,31 +0,0 @@ -### Features - -- **Struggle → AI assist bridge**: when `/brain/struggle-predict` flags a file (EEG focus + undo rate + velocity drop + time-on-file), shows an actionable notification with **Open Copilot Chat**, **Open Terminal**, and **Step Back** buttons. Debounced to one suggestion per file per 10 minutes. Toggle via `neuroskill.struggleBridge`. - -## How it works - -- Monitors `/brain/struggle-predict` (EEG focus + undo rate + velocity drop + time-on-file) -- When `struggling: true`, shows an actionable notification: - > "Stuck on auth.ts? (score: 78) Consider breaking the problem into smaller pieces." - -## Action buttons - -| Button | Action | -|--------|--------| -| **Open Copilot Chat** | Opens GitHub Copilot interactive chat (or generic chat panel) | -| **Open Terminal** | Toggles terminal for CLI debugging | -| **Step Back** | Dismiss and take a mental break | - -## Debouncing - -- Max one suggestion per file per 10 minutes -- Prevents notification fatigue while still catching genuine struggles - -## Settings - -`neuroskill.struggleBridge` (default: `true`) — Enable/disable struggle detection and AI suggestions. - -## Files - -- `src/struggle-bridge.ts` — Struggle bridge implementation (new) -- `src/brain.ts` — Calls `struggleBridge.check()` every 30s (replaces the old generic struggle notification) diff --git a/changes/unreleased/06-flow-triggers-dashboard.md b/changes/unreleased/06-flow-triggers-dashboard.md deleted file mode 100644 index d31f425d..00000000 --- a/changes/unreleased/06-flow-triggers-dashboard.md +++ /dev/null @@ -1,29 +0,0 @@ -### Features - -- **Personal Flow Triggers dashboard**: collapsible "Your Flow Recipe" sidebar section mining 7-day EEG + activity history — best language (`/brain/code-eeg`), peak hours (`/brain/optimal-hours`), natural cycle length (`/brain/break-timing`), top flow killer (`/brain/context-cost`). Toggle via `neuroskill.flowTriggers`. - -## What you see - -In the NeuroSkill sidebar panel, a collapsible "Your Flow Recipe" section shows: - -- **Best language** — "Focus best on Rust (82)" — from `/brain/code-eeg` -- **Peak hours** — "Peak hours: 9:00, 10:00, 14:00" — from `/brain/optimal-hours` -- **Natural cycle** — "Natural cycle: 42m" — from `/brain/break-timing` -- **Flow killer** — "Flow killer: Slack (focus 38 at switch)" — from `/brain/context-cost` - -## Data sources - -| Insight | API Endpoint | Time Range | -|---------|-------------|------------| -| Best languages | `/brain/code-eeg` | Last 7 days | -| Peak hours | `/brain/optimal-hours` | Last 7 days | -| Natural cycle | `/brain/break-timing` | Last 7 days | -| Flow killers | `/brain/context-cost` | Last 7 days | - -## Settings - -`neuroskill.flowTriggers` (default: `true`) — Show/hide the flow triggers section in the sidebar. - -## Files - -- `src/sidebar.ts` — `_fetchFlowTriggers()` and `_renderFlowTriggers()` methods diff --git a/changes/unreleased/07-focus-scored-commits.md b/changes/unreleased/07-focus-scored-commits.md deleted file mode 100644 index bb49b4e1..00000000 --- a/changes/unreleased/07-focus-scored-commits.md +++ /dev/null @@ -1,38 +0,0 @@ -### Features - -- **Focus-scored git commits in sidebar**: collapsible "Recent Commits" section shows the last 8 commits with author marker (👤 human / 🤖 AI) and a color-coded focus badge captured at commit time via `/brain/flow-state`. AI-assisted commits show "AI" instead of a focus score. Toggle via `neuroskill.focusCommits`. - -## What you see - -In the NeuroSkill sidebar, a collapsible "Recent Commits" section shows the last 8 commits: - -``` -👤 82 fix: resolve auth race condition -👤 45 chore: update dependencies -🤖 AI refactor: extract helper functions -👤 71 feat: add user preferences -``` - -- **👤** = human-authored commit -- **🤖** = AI-assisted commit (message generated by Copilot) -- **Focus badge** = color-coded: green (>70), yellow (40-70), red (<40) -- AI commits show "AI" instead of a focus score — AI output doesn't measure human cognition - -## How it works - -- When the extension detects a git commit (SCM input box clears), it: - 1. Snapshots current EEG focus via `/brain/flow-state` - 2. Checks `AIActivityTracker.isCommitAIAssisted()` - 3. Records the commit with focus score + source label -- Commits stored in-memory (last 15), refreshed on sidebar render -- The daemon also stores commits with human/AI distinction in `build_events` - -## Settings - -`neuroskill.focusCommits` (default: `true`) — Show/hide the commits section in the sidebar. - -## Files - -- `src/sidebar.ts` — `recordCommit()`, `_renderCommits()` -- `src/extension.ts` — Wires commit detection to sidebar recording -- `src/events.ts` — `onCommit` callback with human/AI source diff --git a/changes/unreleased/08-optimal-task-router.md b/changes/unreleased/08-optimal-task-router.md deleted file mode 100644 index 982c1ff9..00000000 --- a/changes/unreleased/08-optimal-task-router.md +++ /dev/null @@ -1,29 +0,0 @@ -### Features - -- **Optimal Task Router**: monitors flow score every 30 s and, when it changes by >20 points, suggests an appropriate task type (refactoring/new features at high focus, code review at moderate, docs/routine at low). Debounced to one suggestion per 15 minutes. Toggle via `neuroskill.taskRouter`. - -## How it works - -- Monitors the flow state score every 30 seconds -- When focus changes by >20 points from the last reading, suggests an appropriate task type: - -| Focus Level | Suggestion | -|------------|------------| -| >75 | "Focus is high (85) — great time for complex work like refactoring or new features." | -| 45-75 | "Focus moderate (58) — good for code review, testing, or incremental tasks." | -| <45 | "Focus low (32) — consider documentation, routine tasks, or a break." | - -## Debouncing - -- Maximum one suggestion every 15 minutes -- No suggestion on the first reading (establishes baseline) -- No suggestion if focus stays within 20 points of the last reading - -## Settings - -`neuroskill.taskRouter` (default: `true`) — Enable/disable task routing suggestions. - -## Files - -- `src/task-router.ts` — Task router implementation (new) -- `src/brain.ts` — Calls `taskRouter.check()` every 30s diff --git a/changes/unreleased/09-eeg-heatmap.md b/changes/unreleased/09-eeg-heatmap.md deleted file mode 100644 index 297546f9..00000000 --- a/changes/unreleased/09-eeg-heatmap.md +++ /dev/null @@ -1,29 +0,0 @@ -### UI - -- **EEG focus timeline heatmap**: collapsible "Focus Timeline" sidebar section renders a ~280×36 px SVG sparkline of today's EEG focus (`/brain/eeg-range`, max 120 points), color-graded green/yellow/red, with hour labels and file-name annotations at peaks/valleys (merged from `/activity/timeline`). Toggle via `neuroskill.eegHeatmap`. - -## What you see - -In the NeuroSkill sidebar, a collapsible "Focus Timeline" section shows: - -- A ~280px wide, ~36px tall SVG sparkline -- Color gradient: green (>70 focus), yellow (40-70), red (<40) -- Hour labels along the bottom (0:00, 3:00, 6:00, ...) -- File names annotated at focus peaks and valleys - -## Data sources - -| Data | API Endpoint | -|------|-------------| -| EEG time-series | `/brain/eeg-range` (today, max 120 points) | -| File context | `/activity/timeline` (today, last 200 events) | - -The heatmap merges EEG data points with the closest timeline events to show which files correspond to focus peaks and dips. - -## Settings - -`neuroskill.eegHeatmap` (default: `true`) — Show/hide the heatmap in the sidebar. - -## Files - -- `src/sidebar.ts` — `_fetchHeatmap()` and `_renderHeatmap()` methods diff --git a/changes/unreleased/10-daemon-event-storage.md b/changes/unreleased/10-daemon-event-storage.md deleted file mode 100644 index 21df0863..00000000 --- a/changes/unreleased/10-daemon-event-storage.md +++ /dev/null @@ -1,16 +0,0 @@ -### Bugfixes - -- **Daemon-side event storage**: `git_commit`/`push`/`pull`/`checkout`/`stage`/`unstage`/`stash` events were received but never written to `build_events` (used only for EEG labels). Now persisted, with AI-assisted commits also recorded as `ai_events` (type `"ai_commit"`) and labelled `"git commit (AI)"`. -- **Completion-accepted events were dropped**: previously ignored by the daemon. Now stored as `ai_events` (type `"suggestion_accepted"`) and as edit chunks so they show up in code metrics and AI usage analytics. - -## Impact on analysis - -Brain analysis endpoints can now: -- Count human vs AI commits (`/brain/developer-insights`) -- Track AI suggestion acceptance rates (`/brain/ai-usage`) -- Include git activity in the activity timeline -- Weight human-authored code differently from AI output in focus/productivity scores - -## Files - -- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Event handler updates diff --git a/changes/unreleased/11-shared-daemon-client.md b/changes/unreleased/11-shared-daemon-client.md deleted file mode 100644 index 230205fb..00000000 --- a/changes/unreleased/11-shared-daemon-client.md +++ /dev/null @@ -1,32 +0,0 @@ -### Refactor - -- **Shared `DaemonClient` for VS Code extension**: extracted the repeated `fetch` + auth-token + port-discovery + 3 s timeout pattern into one class. All features now use `client.post(path, body)`. Returns `null` on failure (never throws), with `setToken()` for refresh on reconnect. - -## Before - -Every component (brain.ts, sidebar.ts, extension.ts) independently constructed fetch calls: -```typescript -const port = await discoverDaemonPort(config); -const base = `http://${config.daemonHost}:${port}/v1`; -const headers = { "Content-Type": "application/json" }; -if (token) headers["Authorization"] = `Bearer ${token}`; -const resp = await fetch(`${base}${path}`, { method: "POST", headers, body, signal: AbortSignal.timeout(3000) }); -``` - -## After - -```typescript -const client = new DaemonClient(config, token); -const result = await client.post("/brain/flow-state", { windowSecs: 300 }); -``` - -## Benefits - -- Single place to update auth, timeout, port discovery -- All 8 new features use the shared client -- `setToken()` method for token refresh on reconnect -- Returns `null` on any failure (never throws) — all features handle gracefully - -## Files - -- `src/daemon-client.ts` — DaemonClient class (new) diff --git a/changes/unreleased/feat-activity-dashboard.md b/changes/unreleased/feat-activity-dashboard.md deleted file mode 100644 index 40f22654..00000000 --- a/changes/unreleased/feat-activity-dashboard.md +++ /dev/null @@ -1,19 +0,0 @@ -### Features - -- **Activity dashboard tab**: new Settings tab with daily summary, productivity score (0-100 composite), hourly heatmap, top files/projects, language breakdown, focus sessions, meetings, and stale file alerts. -- **Focus session replay**: click any session to expand a detailed view with file timeline, edit counts, EEG focus overlay bar, and meeting interruptions. -- **Weekly report with CSV export**: 7-day activity digest with daily breakdown chart and one-click CSV download. -- **Productivity scoring**: composite score from edit velocity + deep work time + context stability + EEG focus. -- **Stale file detection**: files edited but untouched for 7+ days. -- **Code-Brain correlation**: focus ranked by language, project, and individual files — shows which code drains or energizes you. -- **Activity timeline**: unified chronological feed of all events (files, builds, meetings, AI, clipboard) with EEG focus indicators. - -### UI - -- **Brain status bar**: persistent bottom bar in Tauri app showing flow state, fatigue, streak, edit velocity, and focus score. -- **Brain dashboard card**: flow/fatigue/streak + focus progress bar on the EEG dashboard alongside existing BrainStateScores. -- **Brain indicators in ActivityTab**: flow state card (green), fatigue alert card (amber), and streak card (violet) at top. - -### i18n - -- Activity dashboard, clipboard, file history, session replay, weekly report, brain status, timeline, and code-brain correlation translations for all 9 locales. diff --git a/changes/unreleased/feat-brain-awareness.md b/changes/unreleased/feat-brain-awareness.md deleted file mode 100644 index 522f25c4..00000000 --- a/changes/unreleased/feat-brain-awareness.md +++ /dev/null @@ -1,12 +0,0 @@ -### Features - -- **Brain awareness API**: 14 endpoints under `/v1/brain/*` — flow state, cognitive load, meeting recovery, optimal hours, fatigue check, undo struggle, daily brain report, break timing, deep work streak, task type detection, struggle prediction, interruption recovery, code-EEG correlation, and unified timeline. -- **Flow state detector**: real-time check if user is in deep focus (high focus + low switches + sustained editing). -- **Task type detection**: auto-classify coding/debugging/reviewing/refactoring/testing from activity patterns. -- **Struggle prediction**: fuse undo rate + velocity drop + focus decline to predict when user is stuck. Includes actionable suggestion. -- **Interruption recovery measurement**: measure actual focus recovery time per interruption source (Slack avg 12min, Zoom avg 23min, etc.). -- **Fatigue monitor**: 15-minute background check broadcasts `fatigue-alert` and sends OS notification when focus declines. -- **Daily brain report**: 6pm OS notification with morning/afternoon/evening brain summary. -- **Weekly digest notification**: Monday 9am OS notification with weekly summary. ISO week dedup prevents re-fire on daemon restart. -- **Break timing optimizer**: detect natural focus cycle length from 5-minute EEG buckets. -- **Deep work streak**: gamified consecutive days with 60+ minutes of deep work. diff --git a/changes/unreleased/feat-brain-insights.md b/changes/unreleased/feat-brain-insights.md deleted file mode 100644 index 991c09f3..00000000 --- a/changes/unreleased/feat-brain-insights.md +++ /dev/null @@ -1,24 +0,0 @@ -### Features - -- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable EEG-fused insights in one call. -- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — shows how focus level predicts test outcomes. -- **Hourly productivity**: churn volume + undo rate + avg EEG focus by hour of day — identifies peak and trough hours. -- **Context switch recovery**: EEG focus level at each editor/terminal/panel transition — measures the cognitive cost of switching. -- **AI tool impact on focus**: per-app (Claude, Pi) focus delta vs baseline — quantifies whether AI tools help or hurt flow state. -- **Focus by language**: avg EEG focus + undo rate per programming language — identifies which languages are cognitively easiest/hardest. -- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day — shows when edit-test loops are fastest. -- **Tool focus impact**: avg EEG focus per command category (docker, git, deploy, etc.) — reveals which tools correlate with lowest focus. -- **EEG timeseries table**: periodic JSON snapshots of all brain metrics (every 5s during recording). Extensible — new metrics without schema changes. Any event correlatable by timestamp join. -- **EEG timeseries worker**: background thread writes full band powers to `eeg_timeseries` every 5s when an EEG session is active. -- **`POST /v1/brain/eeg-at`**: get EEG metrics at a specific timestamp (nearest sample). -- **`POST /v1/brain/eeg-range`**: get EEG time-series in a range for charts and correlation analysis. -- **Window focus tracking**: `window_focus` events sent when VS Code gains/loses focus. EEG not attributed to coding when VS Code is in background. -- **Live EEG attachment**: `latest_bands` focus/mood injected at event storage time for terminal commands, zone switches, and conversations. - -### UI - -- **Tauri Brain Insights section** in Activity tab: test failure rates by focus level, focus by language bars, AI impact delta, tool focus grid, hourly productivity chart. -- **Tauri EEG focus inline**: terminal commands and conversation messages show EEG focus score (green/yellow/red) at the moment they occurred. -- **VS Code Brain Insights card**: test failure by focus chips, focus by language bars, tool impact chips. Only shown when EEG data is available. -- **VS Code Struggle card**: hidden when no EEG recording (focus is EEG-only, not activity-based). -- **VS Code Flow gauge label**: shows "focus" with EEG, "activity" without — no false claim of brain measurement. diff --git a/changes/unreleased/feat-burn-mlx.md b/changes/unreleased/feat-burn-mlx.md deleted file mode 100644 index f4d152ac..00000000 --- a/changes/unreleased/feat-burn-mlx.md +++ /dev/null @@ -1,9 +0,0 @@ -### Dependencies - -- **burn-mlx**: added `burn-mlx` from git (`eidola-ai/burn-mlx`, branch `burn-0-20`) as optional dependency in `skill-router` and `skill-daemon`. Workspace `[patch.crates-io]` redirects all `burn-mlx` references to the git version (crates.io 0.1.2 targets burn 0.16; project uses burn 0.20). -- **macOS deployment target**: bumped from 10.15 to 14.0 in `.cargo/config.toml` for Metal 3.0 `simdgroup_matrix` intrinsics required by MLX. Still satisfies llama.cpp's `std::filesystem` (available since 10.15). -- **half 2.4**: added to `skill-router` for GPU f16 precision backend type (`CubeBackend`). - -### Features - -- **`mlx-e2e` test suite**: new suite in `scripts/test-all.sh` running UMAP and FFT e2e tests with MLX features. Auto-skips on non-macOS. Available via `npm run test:mlx-e2e`. diff --git a/changes/unreleased/feat-conversations-search.md b/changes/unreleased/feat-conversations-search.md deleted file mode 100644 index de4ad4ea..00000000 --- a/changes/unreleased/feat-conversations-search.md +++ /dev/null @@ -1,18 +0,0 @@ -### Features - -- **Conversations table**: stores all AI coding assistant messages (Claude, Pi) with app, role (user/assistant/tool), text, cwd, timestamp, session ID, EEG focus/mood. -- **FTS5 full-text search**: SQLite virtual table for instant keyword search across all conversation text. `POST /v1/brain/search-conversations {"query":"JWT","mode":"fts"}`. -- **Fuzzy search**: LIKE-based substring matching for partial queries. `{"query":"rate limit","mode":"fuzzy"}`. -- **Structured search**: filter by app, role, time range. `{"mode":"structured","app":"claude","role":"user","since":...}`. -- **Semantic search**: user prompts embedded via fastembed (nomic-embed-text-v1.5, local, no API credits) and stored in HNSW label index. Searchable by meaning, not just keywords. -- **Generic embedding store**: `embeddings` table decoupled from specific data tables. Multi-model support — can re-embed with different models, store multiple vectors per item. Source tracking (source_type, source_id). -- **Code context HNSW index**: separate `code_context_index.hnsw` file for code-specific semantic search, keeping EEG label searches uncontaminated. -- **Conversation events**: VS Code extension sends `conversation_message` events to daemon for each Claude/Pi message. User prompts get embedded; assistant responses and tool calls get FTS-indexed only (saves compute). -- **Session tracking**: messages grouped by JSONL filename (session ID). Timestamps from app's own data (ISO 8601 UTC), not extension read time. -- **Embedding settings**: `neuroskill.embedding.maxInputLength` (default 1000 chars) and `neuroskill.embedding.enableConversations` (default true) in VS Code settings. - -### UI - -- **Tauri AI Conversations section** in Activity tab: timestamped message thread with role icons (user/assistant/tool), app name, EEG focus score, scrollable. -- **VS Code Conversations card**: timestamped messages with role icons, app badge, collapsible. -- **VS Code AI Usage card**: Copilot/Codeium acceptance rate bar, suggestion count, source breakdown chips. diff --git a/changes/unreleased/feat-data-collection.md b/changes/unreleased/feat-data-collection.md deleted file mode 100644 index 607e5934..00000000 --- a/changes/unreleased/feat-data-collection.md +++ /dev/null @@ -1,12 +0,0 @@ -### Features - -- **Meeting detection**: automatically detect Zoom, Teams, Slack, Google Meet, FaceTime, Discord, and Webex meetings from window titles. Track start/end times in `meeting_events` table. -- **Browser tab extraction**: extract page titles from Chrome, Safari, Firefox, Edge, Brave, Arc, Opera, Vivaldi, Chromium. Stored in `browser_title` column on `active_windows`. -- **Clipboard monitoring**: opt-in macOS clipboard change tracking (metadata only — content never stored). Records source app, content type, and size. Includes Automation permission check and settings UI. -- **Multi-monitor awareness**: track windows on secondary monitors across macOS (AppleScript), Linux (wmctrl), Windows (EnumWindows). Dynamic primary screen resolution detection. -- **Undo frequency tracking**: detect undo/redo from file edit diffs via reversal heuristic. `undo_estimate` per 5-second chunk, `undo_count` per file interaction. - -### Bugfixes - -- **Schema migration**: ALTER TABLE for existing databases missing columns added across releases. Runs before DDL to handle all legacy schemas. -- **Retention pruning**: meetings, clipboard, and secondary_windows pruned alongside file interactions during hourly maintenance. diff --git a/changes/unreleased/feat-dependabot-fixes.md b/changes/unreleased/feat-dependabot-fixes.md deleted file mode 100644 index 6c79a615..00000000 --- a/changes/unreleased/feat-dependabot-fixes.md +++ /dev/null @@ -1,11 +0,0 @@ -### Dependencies - -- **Fix rand 0.7.3 vulnerability**: vendored `phf_generator 0.8.0` with rand bumped from 0.7 to 0.8, eliminating the vulnerable transitive dependency. -- **Remove atty**: migrated `iroh_test_client` from `structopt` (clap v2) to `clap v4` derive, dropping the unmaintained `atty` crate. -- **Update llama-cpp-4 to 0.2.50**: upstream llama.cpp fixes including `common_*` symbol renames. -- **Update kittentts to 0.4.1**: TTS engine update. -- **Add grayscale 0.0.1**: macOS grayscale display control for DND mode. - -### Bugfixes - -- **Dismiss stale Dependabot alerts**: crossbeam-deque, crossbeam-utils, crossbeam-queue, memoffset, and glib alerts dismissed (already at patched versions or fixed in fork). diff --git a/changes/unreleased/feat-design-system.md b/changes/unreleased/feat-design-system.md deleted file mode 100644 index ae6b1838..00000000 --- a/changes/unreleased/feat-design-system.md +++ /dev/null @@ -1,19 +0,0 @@ -### Features - -- **Design tokens** (`webview-ui/src/lib/tokens.css`): 30+ CSS custom properties organized into core palette (5 colors), surfaces (4 levels), backgrounds (14 translucent tints), borders (3 colors), semantic aliases (7), and typography (3). Zero inline `rgba()` in App.svelte. -- **Reusable Svelte components** (`webview-ui/src/lib/`): - - `Card` — wrapper with title, info button, info panel, header-right slot, variant borders (warn/danger/info) - - `MetricRow` — label + value pair with variant colors (warn/accent/dim/good/bad) - - `Chevron` — collapsible section with chevron toggle, count badge, slot content - - `ProgressBar` — horizontal bar with value/max, 5 color variants, optional label - - `Gauge` — circular SVG ring with animated fill, value, label - - `Badge` — text badge with 7 variants (default/good/warn/bad/blue/live/score-circle/si) - - `Callout` — alert box with 3 variants (warn/danger/info) -- **Component index** (`webview-ui/src/lib/index.ts`): barrel export for all components. -- **Timestamp compliance**: 25 automated tests (`src/test-timestamps.ts`) verifying: - - No `toLocaleTimeString`/`toLocaleDateString` in data layer (activity-tracker, events, brain, config, vt-parser) - - `toLocaleTimeString` used in UI layer (App.svelte) for display - - `Date.now()` returns UTC milliseconds - - ISO 8601 strings parsed to UTC millis - - No hardcoded timezone offsets in data layer - - All stored timestamps are UTC; local conversion only at UI boundary diff --git a/changes/unreleased/feat-dev-loops.md b/changes/unreleased/feat-dev-loops.md deleted file mode 100644 index 7bcb5493..00000000 --- a/changes/unreleased/feat-dev-loops.md +++ /dev/null @@ -1,10 +0,0 @@ -### Features - -- **Dev loop detection**: identifies edit-build-test cycles from terminal command history. Groups consecutive runs of the same build/test command into loops. -- **`dev_loops` table**: loop type, command, iteration count, pass/fail counts, avg cycle time, fastest/slowest cycle, EEG focus start/end, focus trend (rising/falling/stable). -- **`POST /v1/brain/dev-loops`**: returns detected loops for a time window with all metrics. -- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages." -- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl → infrastructure (0.8), deploy → deploying (0.85), git dominating → git_management (0.7), test → testing (0.85), debug → debugging (0.9). -- **Dev loops in sidebar**: command, iteration count, pass rate, avg cycle time, focus trend arrow. Failing loops get red left border. -- **Dev loops in Tauri Activity tab**: expanded view with pass rate percentage, cycle time, focus trend. -- **Loop efficiency by hour**: insight showing build/test cycle time + pass rate by time of day. diff --git a/changes/unreleased/feat-exg-inference-backend.md b/changes/unreleased/feat-exg-inference-backend.md deleted file mode 100644 index 2ac7759d..00000000 --- a/changes/unreleased/feat-exg-inference-backend.md +++ /dev/null @@ -1,9 +0,0 @@ -### Features - -- **Inference backend selector**: `exg_inference_device` setting expanded from `gpu | cpu` to `auto | mlx | gpu | cpu`. Default changed from `gpu` to `auto` (picks MLX on macOS, GPU elsewhere). -- **Four-button selector in EXG Settings**: Auto, MLX, GPU, CPU — each with localized label and description. Reconnect headset hint shown after change. -- **`embed-exg-mlx` feature**: new daemon Cargo feature that enables `skill-router/mlx`, `skill-eeg/mlx`, and `embed-zuna-mlx` together. - -### i18n - -- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). diff --git a/changes/unreleased/feat-fast-umap-1.6.md b/changes/unreleased/feat-fast-umap-1.6.md deleted file mode 100644 index 7c5ef60b..00000000 --- a/changes/unreleased/feat-fast-umap-1.6.md +++ /dev/null @@ -1,24 +0,0 @@ -### Features - -- **fast-umap 1.6.0**: updated from 1.5.1 with MLX, PCA, and nearest-neighbor descent support. -- **MLX UMAP backend**: Apple Silicon native UMAP projection via `burn-mlx`. Runtime dispatch between MLX and GPU (wgpu) based on user preference. Auto defaults to MLX on macOS, GPU elsewhere. -- **Precision selector**: F32 / F16 precision for the GPU (wgpu) backend. MLX is F32-only (fast-umap trait constraint). Exposed in UMAP settings as chip group. -- **Backend & precision UI**: new "Compute Backend" section in UMAP settings with Auto / MLX / GPU chips and F32 / F16 precision chips. Pipeline summary badge shows active backend and precision. - -### Performance - -- **MLX vs GPU benchmarks** (Mac mini, Apple Silicon): - -| Dataset | Points | GPU (wgpu) | MLX | Speedup | -|---|---|---|---|---| -| Small | 200 | 120.9 s | 2.3 s | **51x** | -| Medium | 1,000 | 136.6 s | 7.1 s | **19x** | -| Large | 5,000 | 152.8 s | 23.8 s | **6.4x** | - -### Features - -- **UMAP e2e benchmarks**: `umap_e2e_small` (200 pts), `umap_e2e_medium` (1K pts), `umap_e2e_large` (5K pts) with synthetic 32-dim EEG embeddings. Reports backend, timing, throughput (pts/sec), and separation score. - -### i18n - -- Added `umapSettings.backend`, `umapSettings.backendDesc`, `umapSettings.precision`, `umapSettings.precisionDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). diff --git a/changes/unreleased/feat-gpu-fft-1.2.md b/changes/unreleased/feat-gpu-fft-1.2.md deleted file mode 100644 index c6f6a6d8..00000000 --- a/changes/unreleased/feat-gpu-fft-1.2.md +++ /dev/null @@ -1,8 +0,0 @@ -### Features - -- **gpu-fft 1.2.0**: updated from 1.1.1 with MLX FFT backend. EEG signal filtering (overlap-save convolution) uses MLX when `skill-eeg/mlx` is enabled. ~3.7x faster than wgpu at N=65536. -- **`skill-eeg/mlx` feature**: new feature gate that enables `gpu-fft/mlx` alongside `gpu-fft/wgpu`. Wired into `embed-exg-mlx` daemon feature. - -### Features - -- **FFT MLX e2e**: `fft_e2e_roundtrip_256`, `fft_e2e_batch_4ch`, `fft_e2e_psd_peak_detection`, `fft_e2e_large_batch` (32 channels x 1024 samples). Validates round-trip accuracy, PSD peak detection, and batch throughput. diff --git a/changes/unreleased/feat-grayscale-dnd.md b/changes/unreleased/feat-grayscale-dnd.md deleted file mode 100644 index 0c26d0cf..00000000 --- a/changes/unreleased/feat-grayscale-dnd.md +++ /dev/null @@ -1,7 +0,0 @@ -### Features - -- **Grayscale display mode**: optional system-wide grayscale that activates/deactivates in sync with Do Not Disturb. Reduces visual distraction during deep work. macOS only, default off. - -### i18n - -- Added `dnd.grayscale` and `dnd.grayscaleDesc` translations to all 9 locales. diff --git a/changes/unreleased/feat-history-search-integration.md b/changes/unreleased/feat-history-search-integration.md deleted file mode 100644 index d41f7a37..00000000 --- a/changes/unreleased/feat-history-search-integration.md +++ /dev/null @@ -1,5 +0,0 @@ -### Features - -- **File activity in history sessions**: expanded EEG sessions show files worked on during the session with focus indicators, edit counts (+/-), language, and meeting interruption badges. -- **File activity in interactive search**: `file_activity` nodes in the 3D search graph linked to EEG epochs and text labels, showing which files were being edited during matching brain states. -- **Meeting nodes in search graph**: detected meetings appear alongside EEG results in the interactive search as amber nodes with temporal proximity edges. diff --git a/changes/unreleased/feat-neuroskill-cli.md b/changes/unreleased/feat-neuroskill-cli.md deleted file mode 100644 index 23eec985..00000000 --- a/changes/unreleased/feat-neuroskill-cli.md +++ /dev/null @@ -1,5 +0,0 @@ -### CLI - -- **`neuroskill activity` expanded**: 19 subcommands — bands, window, input, windows, files, top-files, top-projects, languages, sessions, meetings, clipboard, builds, heatmap, switches, summary, score, digest, stale, timeline. -- **`neuroskill brain` added**: 14 subcommands — flow, load, load-files, recovery, optimal, fatigue, struggle, report, breaks, streak, task, stuck, interruptions, correlation. -- **`neuroskill vscode` added**: auto-build and install VS Code/VSCodium/Cursor extension from source on macOS, Linux, and Windows. diff --git a/changes/unreleased/feat-search-ui-redesign.md b/changes/unreleased/feat-search-ui-redesign.md deleted file mode 100644 index 6a3104d6..00000000 --- a/changes/unreleased/feat-search-ui-redesign.md +++ /dev/null @@ -1,11 +0,0 @@ -### UI - -- **Search mode dropdown**: replaced 4-tab bar with a styled dropdown supporting 7 search modes — Interactive, EEG, Text, Images, Code, Meetings, Brain. -- **Diamond EEG nodes**: EEG epochs rendered as faceted octahedrons (diamonds) in the 3D search graph. Meeting nodes rendered as tetrahedrons (pyramids). Visual distinction by data type. -- **New search modes**: Code (file activity + brain state), Meetings (meeting events + recovery), Brain (flow/fatigue/struggle insights). -- **Terminal commands in Cmd-K**: added 4 terminal commands to the command palette — Recent Terminal Commands, Terminal Focus Impact, Context Switch Cost, Dev Loops. Translated labels in all 9 locales. - -### i18n - -- Search mode labels (Code, Meetings, Brain) translated in all 9 locales. -- Terminal command palette entries translated in all 9 locales. diff --git a/changes/unreleased/feat-sidebar-cards.md b/changes/unreleased/feat-sidebar-cards.md deleted file mode 100644 index ee0c48e0..00000000 --- a/changes/unreleased/feat-sidebar-cards.md +++ /dev/null @@ -1,30 +0,0 @@ -### Features - -- **Git context card**: current branch (monospace), dirty/staged file count, ahead/behind indicators. Uses VS Code git extension API. -- **Session timeline card**: horizontal bar chart showing activity events by hour of day. Color-coded by volume. -- **Workspace activity card**: per-file edits, lines added/removed, focus time. Grouped by workspace folder (project), sorted by activity. Active file indicator (blue dot). Top 10 files per project. -- **Environment card**: editor/terminal/panel time split (colored stacked bar), tab count, editor groups, terminal count. Per-terminal cards with shell type, CWD, PID, shell integration status. -- **Terminal I/O sections**: collapsible with chevrons, minimized by default. Input shows timestamped commands with CWD. Output shows VT-parsed session content. -- **Terminal input card**: keystroke intensity per program (keys/min), duration, collapsible behind chevron. -- **Terminal impact card**: EEG focus delta by command category with pass rate percentage. -- **Context switch cost card**: focus level at each zone transition type with switch count. -- **Dev loops card**: edit-build-test cycles with iteration count, pass/fail rate, cycle time, focus trend (rising/falling/stable arrow). -- **Today's report card**: productivity score, morning/afternoon/evening period breakdown with focus bars and churn. -- **Energy card**: fatigue bar (inverse of focus decline), continuous work time, streak badge. -- **Struggle card**: EEG-only (hidden without recording), score 0-100, contributing factors. -- **Optimal hours card**: peak/avoid hours grid. -- **AI usage card**: acceptance rate bar, suggestion count, source breakdown. -- **Today vs yesterday card**: files and churn comparison with directional arrows. -- **Code review detection**: auto-detected when file switches > 5 and edit velocity < 1 line/min. -- **Process monitor card**: running dev servers (vite, next, webpack, cargo, node, python, docker, postgres) with ports and PIDs. -- **Info toggles**: every card has a `?` button explaining how metrics are calculated. -- **Dual-speed updates**: local data every 5s (visible), brain data every 30s. Pauses when sidebar hidden. - -### UI - -- **Configurable alerts**: `neuroskill.alerts.focusLow` (warn when EEG focus drops below threshold), `neuroskill.alerts.continuousWorkMins` (warn after N minutes continuous work). -- **Daily digest notification**: at configurable time (default 17:30), shows productivity score + stats. "Full Report" button opens detail panel. -- **Full report panel**: `Cmd+Shift+R` opens full-width webview with same data as sidebar. -- **Keyboard shortcuts**: `Cmd+Shift+N` (toggle sidebar), `Cmd+Shift+R` (full report), `Cmd+Shift+Alt+N` (reconnect). -- **Open NeuroSkill button**: launches native app (cross-platform). -- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk. Auto-flushes on reconnect. diff --git a/changes/unreleased/feat-terminal-tracking.md b/changes/unreleased/feat-terminal-tracking.md deleted file mode 100644 index 9e7e3b51..00000000 --- a/changes/unreleased/feat-terminal-tracking.md +++ /dev/null @@ -1,24 +0,0 @@ -### Features - -- **OS-wide shell hooks**: preexec/precmd hooks for zsh, bash, fish, PowerShell. Background curl sends every command to daemon — zero delay to prompt. Self-contained scripts generated by daemon, stored in `~/.skill/shell-hooks/`. -- **Shell hook management**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check per shell. -- **Terminal command table**: `terminal_commands` with command text, binary name, args, cwd, exit code, duration, auto-categorized (284 CLI tools across 18 categories), EEG focus at start + end for delta calculation. -- **Binary extraction**: raw binary name stored separately from args. Lazy categorization — `POST /v1/brain/recategorize-commands` reruns rules on all historical data. `POST /v1/brain/binary-stats` discovers uncategorized tools by frequency. -- **284 CLI tools recognized**: git, gh, glab, hf, docker, kubectl, helm, aws, gcloud, az, brew, pip, cargo, npm, psql, redis-cli, ollama, claude, pi, vim, tmux, htop, terraform, and 260+ more across 18 categories (build, test, run, git, docker, deploy, install, navigate, debug, network, database, ai, editor, multiplexer, monitor, env, system, other). -- **Script session recording**: `script` PTY proxy wraps shell sessions, captures all terminal I/O (including input inside interactive programs). Logs stored in `~/.skill/terminal-logs/` with auto-rotation. -- **VT100 terminal emulator**: `vt-parser.ts` replays script recordings through a virtual terminal with scrollback capture. Handles cursor movement, screen clears, alternate screen buffer. Separates input from output via prompt pattern detection. -- **App-specific JSONL parsing**: reads Claude Code (`~/.claude/projects/`) and Pi agent (`~/.pi/agent/sessions/`) conversation files directly. Extracts user prompts with real timestamps, assistant responses, and tool calls. Supports multiple parallel sessions. -- **Readline history polling**: monitors `~/.python_history`, `~/.node_repl_history`, `~/.irb_history`, `~/.psql_history` for REPL input. -- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). -- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons with health indicators (green/yellow/red dot), recent commands preview, "How it works" documentation. - -### Server - -- **`POST /v1/activity/shell-command`**: receives commands from shell hooks with command text, cwd, shell type, exit code. -- **`POST /v1/brain/terminal-commands`**: query recent commands with exit codes, durations, categories, EEG correlation. -- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category. -- **`POST /v1/brain/binary-stats`**: usage frequency per binary for discovering uncategorized tools. -- **`POST /v1/brain/recategorize-commands`**: rerun categorization rules on all historical commands. -- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end. -- **Zone switch events**: editor/terminal/panel transitions stored with EEG focus snapshot. `POST /v1/brain/context-cost` returns focus at each transition type. -- **Layout snapshots**: periodic (60s) tab/group/terminal counts from VS Code. diff --git a/changes/unreleased/feat-validation-daemon.md b/changes/unreleased/feat-validation-daemon.md deleted file mode 100644 index c32daedf..00000000 --- a/changes/unreleased/feat-validation-daemon.md +++ /dev/null @@ -1,16 +0,0 @@ -### Features - -- **Validation / fatigue-research daemon backend**: foundations for calibrating the Break Coach and Focus Score against four external instruments — KSS (Karolinska Sleepiness Scale), NASA-TLX (workload), PVT (Psychomotor Vigilance Task), and an EEG-derived fatigue index per Jap et al. 2009. -- **`skill-data::validation_store`** — new SQLite store at `~/.skill/validation.sqlite` with five tables (`config`, `kss_responses`, `tlx_responses`, `pvt_runs`, `prompt_log`). Persistent config is a single-row JSON blob with serde defaults so adding a new channel later is a non-migration. New constant `VALIDATION_FILE` in `skill-constants`. -- **EEG fatigue index**: pure function `eeg_fatigue_index(bands) → Option` computing `(α + θ) / β` over the existing band-power snapshot. Guards against missing bands and zero-β. Passive; ships **on** because it costs nothing when no headset is attached. -- **Pure scheduler `decide_prompt(ctx, ...) → PromptDecision`**: respects the `respect_flow` master gate, configurable quiet hours, per-channel daily caps, runtime snoozes, and rate limits. Channel ordering: KSS first (lightest), TLX after a long task unit, PVT on a weekly cadence. Live `read_in_flow` and `read_break_coach_active` open the activity store read-only and ask `flow_state_now(300).in_flow` and `fatigue_check().fatigued` so the gates actually mean something. -- **Sane defaults — opt-in everywhere**: KSS, TLX, PVT all ship `enabled = false`. Only the passive EEG fatigue index ships on. The `respect_flow` master gate ships on. - -### Server - -- **`/v1/validation/*` HTTP endpoints** (axum): `GET /config`, `PATCH /config` (recursive JSON merge for partial updates), `POST /snooze`, `POST /disable-today`, `GET /should-prompt` (returns the daemon's prompt decision; logs the fire so the rate-limiter sees it), `POST /kss`, `POST /tlx`, `POST /pvt`, `POST /close-prompt`, `GET /results`, `GET /fatigue-index` (live `(α+θ)/β` from the latest band snapshot). -- **`AppState.validation_runtime`** (`crates/skill-daemon-state/src/state.rs`): new `Arc>` field holding ephemeral snooze/disable-today state. Resets on daemon restart by design — a crash loop shouldn't keep prompts suppressed forever. - -### Bugfixes - -- **CORS allow-methods now includes `PATCH`**: `crates/skill-daemon/src/main.rs` was advertising only `GET, POST, PUT, DELETE, OPTIONS`, so the browser preflight blocked `PATCH /v1/validation/config` from the Tauri webview. Added `Method::PATCH` to the list. diff --git a/changes/unreleased/feat-validation-tauri-ui.md b/changes/unreleased/feat-validation-tauri-ui.md deleted file mode 100644 index b5be6ae7..00000000 --- a/changes/unreleased/feat-validation-tauri-ui.md +++ /dev/null @@ -1,18 +0,0 @@ -### Features - -- **Validation & Research settings tab** (`src/lib/settings/ValidationTab.svelte`): new top-level settings tab gathering five `SettingsCard` blocks — global gates (`respect_flow`, quiet hours), KSS, NASA-TLX, PVT, and EEG fatigue index — plus a Calibration Week button and a recent-results card. - - Every toggle / numeric input PATCHes one nested field via `daemonPatch("/v1/validation/config", …)`; the daemon's recursive JSON merge keeps the rest of the config untouched. - - EEG fatigue card shows the live `(α + θ) / β` value polled every 5 s from `/v1/validation/fatigue-index`. - - Calibration Week: single button that batch-updates all four channels to higher-frequency presets (KSS 8/day, TLX after every flow block ≥ 20 min, PVT mid-week) in one PATCH. -- **PVT panel** (`src/lib/settings/PvtPanel.svelte`): full 3-minute Psychomotor Vigilance Task. Random ITIs (2–10 s), green-dot stimulus, `performance.now()` for sub-millisecond RT measurement, tracks responses + false starts. On finish computes mean RT, median RT, slowest-10% mean RT (anticipation-resistant), and lapse count (RT > 500 ms per Dinges & Powell 1985), POSTs to `/v1/validation/pvt`. State machine: intro → running → done. -- **NASA-TLX form** (`src/lib/settings/TlxForm.svelte`): six-slider modal for the raw (un-weighted) NASA-TLX. Performance scale uses inverted endpoints ("Failure" → "Perfect") per Hart 2006. POSTs to `/v1/validation/tlx` with `task_kind`, `task_duration_secs`, optional `prompt_id` echo-back. -- **Pure stats helper** (`src/lib/settings/pvt-stats.ts`): extracted `mean`, `median`, `slowest10Mean`, `lapseCount`, `computeStats` from the PVT panel so the math is unit-testable without a Svelte renderer. -- **`daemonPatch(path, body)`** in `src/lib/daemon/http.ts` — needed for the validation config endpoint. - -### UI - -- **Spacing pass on the validation tab and modals**: every card uses `flex flex-col gap-5 p-6` instead of the old `space-y-4` (which provided no padding override). Conditional sub-settings under a channel toggle are now indented under a left border (`ml-1 border-l-2 border-border pl-5`) so the parent/child relationship reads visually. Modals padded to `p-8` with `gap-3` button rows. TLX sliders gained `mt-1` lift off the description and `uppercase tracking-wide` low/high legend labels. - -### i18n - -- **New `validation` namespace** in all 9 languages (`src/lib/i18n/{de,en,es,fr,he,ja,ko,uk,zh}/validation.ts`): ~95 keys in English (full coverage), ~55 in each other language for the user-visible strings (long-tail descriptions fall back via the runtime `t()` chain). `index.ts` barrel exports updated for every language. diff --git a/changes/unreleased/feat-validation-tests.md b/changes/unreleased/feat-validation-tests.md deleted file mode 100644 index faeaccb2..00000000 --- a/changes/unreleased/feat-validation-tests.md +++ /dev/null @@ -1,11 +0,0 @@ -### Features - -- **Test coverage for the validation feature across all three layers** — 72 tests total, all passing. - - **Rust unit tests** (`crates/skill-data/src/validation_store.rs`): 19 tests covering config persistence, store round-trips, the `(α + θ) / β` fatigue index (Jap et al. 2009) including zero-β and missing-band edge cases, every scheduler branch (flow respect, quiet hours, snooze blocking, KSS rate limit, TLX trigger after long task, TLX skip on short task, PVT weekly cadence, PVT silence after recent run, quiet-window-equals-end semantics), and prompt-log queries. - - **Rust HTTP integration tests** (`crates/skill-daemon/src/routes/validation.rs`): 10 tests using `tower::ServiceExt::oneshot()` against a tempdir-backed `AppState` — `GET /config` defaults, `PATCH /config` partial-update + round-trip, `POST /kss` happy path, KSS score-out-of-range → 400, TLX subscale-out-of-range → 400, `GET /should-prompt` returns `{kind: "none"}` with default config, `POST /snooze` envelope, plus three `merge_json` tests covering deep nesting. - - **Vitest — PVT statistics** (`src/tests/pvt-stats.test.ts`, 12 tests): edge cases for `mean`, `median`, `slowest10Mean` (including the n<10 fallback), `lapseCount` with the 500 ms Dinges & Powell threshold and explicit-threshold override, plus a known-fixture `computeStats` round-trip. - - **Vitest — i18n coverage** (`src/tests/validation-i18n.test.ts`, 23 tests): every VS Code l10n bundle exists and contains every user-facing key (KSS prompt + scores 1/5/9, TLX/PVT prompts, all four escape-hatch labels); every Tauri language `validation.ts` imports cleanly; English Tauri bundle covers every required key; every non-English Tauri bundle has at least the tab name and disclaimer translated. - -### Refactor - -- **Sidebar disclaimer test now accepts either localiser**: existing `vscode-sidebar-disclaimer.test.ts` updated so the `vscode.l10n.t("sidebar.disclaimer")` ↔ `tr("sidebar.disclaimer")` migration doesn't break the assertion. Both look up the same key. diff --git a/changes/unreleased/feat-validation-vscode-extension.md b/changes/unreleased/feat-validation-vscode-extension.md deleted file mode 100644 index ae058eb6..00000000 --- a/changes/unreleased/feat-validation-vscode-extension.md +++ /dev/null @@ -1,13 +0,0 @@ -### Features - -- **VS Code extension joins the validation prompt loop**: new `ValidationManager` (`extensions/vscode/src/validation.ts`) polls `/v1/validation/should-prompt` every 90 s and renders whichever channel the daemon decides to fire. - - **KSS** (1–9 sleepiness) — full QuickPick with Karolinska wording, plus Snooze 30m / Don't ask today / Stop these prompts escape hatches in-line. POSTs the answer to `/v1/validation/kss` echoing the daemon's `prompt_id` so the prompt log can mark it answered. - - **NASA-TLX** — fallback path: shows an information message offering to open the form in the Tauri app (deep link `neuroskill://validation/tlx`) plus the same escape hatches. - - **PVT** — weekly nudge: offers to deep-link into the Tauri PVT panel (`neuroskill://validation/pvt`) or skip a week (snoozes for 6 days so the next reminder fires on cadence). -- **Two new commands**: `NeuroSkill: Open Validation Settings…` (deep links into the Tauri preferences pane) and `NeuroSkill: Check for Validation Prompt Now` (forces a single scheduler poll — useful for opt-in onboarding). -- **`DaemonClient.patch()`**: extension's daemon client gained a PATCH method so the "Stop these prompts" escape hatch can flip `enabled = false` on the persistent config. - -### i18n - -- **22 new `validation.*` keys per bundle** in all 9 languages (`bundle.l10n.{de,en,es,fr,he,ja,ko,uk,zh-cn}.json`): KSS prompt + 9 score labels with translated Karolinska wording, TLX/PVT prompts, all four escape-hatch labels, the disabled-permanently acknowledgement. -- **Two new `cmd.*` keys** for the validation commands in `package.nls.json`. diff --git a/changes/unreleased/feat-vscode-extension-ci.md b/changes/unreleased/feat-vscode-extension-ci.md deleted file mode 100644 index 0efee66b..00000000 --- a/changes/unreleased/feat-vscode-extension-ci.md +++ /dev/null @@ -1,7 +0,0 @@ -### Build - -- **CI for the VS Code extension** (`extensions/vscode/.github/workflows/`): two workflows live in the submodule repo (`vscode-neuroskill`). - - **`ci.yml`** — runs on every PR and `main` push. `npm ci` → `tsc` build → `vsce package` → uploads the `.vsix` as a 14-day artifact. No secrets, no publish. - - **`release.yml`** — fires two ways. *Tag push* (`git push --tags` after `npm version patch`) verifies the tag matches `package.json` and publishes. *Manual dispatch* with a `patch | minor | major | x.y.z` input bumps `package.json`, commits, tags, pushes, then publishes. Both paths run `npx @vscode/vsce publish` (uses `VSCE_PAT`) and `npx ovsx publish` (uses `OVSX_PAT`), then create a GitHub release with the `.vsix` attached and the latest `## ` block from `CHANGELOG.md` as release notes. -- **Skip-on-missing-secret**: if either `VSCE_PAT` or `OVSX_PAT` is unset, the corresponding publish step emits a `::warning::` and is skipped — letting you wire up Open VSX later without breaking the workflow. -- **Releasing section in the extension README**: documents the secret setup (Azure DevOps PAT, Open VSX token), the two release flows, and the one-time namespace claims (Marketplace publisher registration + `ovsx create-namespace`). diff --git a/changes/unreleased/feat-vscode-extension.md b/changes/unreleased/feat-vscode-extension.md deleted file mode 100644 index dedc2f14..00000000 --- a/changes/unreleased/feat-vscode-extension.md +++ /dev/null @@ -1,84 +0,0 @@ -### Features - -- **VS Code extension**: separate repo (`NeuroSkill-com/vscode-neuroskill`) as git submodule at `extensions/vscode/`. 50+ tracked event types across editing, navigation, debugging, git, AI, terminal, clipboard, and more. -- **Sidebar webview (Svelte 5)**: full brain dashboard in the VS Code activity bar with circular flow gauge, metrics strip, daily report, energy, struggle, optimal hours, workspace activity, environment, terminal impact, context cost, and dev loops. -- **Activity bar icon**: neural network SVG icon in the VS Code sidebar; NeuroSkill logo (PNG) in the webview header. -- **Brain status bar in VS Code**: polls daemon every 30s showing flow state, fatigue, streak, task type, and struggle score. Shows "offline" when daemon unreachable. Notifications for fatigue and struggle. -- **Dual-speed sidebar updates**: local data (files, terminals, layout) refreshes every 5s when sidebar is visible; brain endpoints every 30s. Pauses when hidden. -- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk via `globalStorageUri`. Auto-flushes on reconnect. Survives VS Code restarts. Cache count shown in status bar tooltip. -- **Workspace activity tracking**: per-file edits, lines added/removed, focus time, active file indicator. Grouped by workspace folder (project), sorted by activity. Top 10 files per project. -- **Terminal command tracking**: full command text, exit code, cwd, output streaming (via `execution.read()`), shell type detection (zsh/bash/fish/powershell/cmd/node/python), focus time per terminal, PID. Expandable command output in sidebar. -- **Terminal shell integration**: captures commands via `onDidStartTerminalShellExecution` / `onDidEndTerminalShellExecution` (VS Code 1.93+). CWD tracked via `onDidChangeTerminalShellIntegration`. -- **Zone tracking**: editor/terminal/panel time split with stacked color bar (blue/green/yellow). Layout chips showing tab count, editor groups, terminal count. -- **Auto EEG labeling**: every significant VS Code event auto-inserts a searchable label into EEG recordings with smart categorization (editing, debugging, git commits, AI assistance, meetings, errors, navigation, terminal commands, zone switches). -- **Inline label embedding**: auto-labels embedded immediately via fastembed for instant searchability (no idle reembed wait). -- **Command execution tracking**: 40+ VS Code commands tracked (go-to-definition, rename, find, format, fold, git, AI, debug, layout) with semantic categorization. -- **IntelliSense acceptance detection**: multi-char single-line insertions heuristically identified as autocomplete acceptances. -- **Clipboard tracking**: clipboard content changes polled every 5s with debounce. -- **Layout snapshots**: periodic (60s) capture of editor groups, visible editors, open tabs, terminal count sent to daemon. -- **Zone switch events**: editor/terminal/panel transitions sent to daemon with EEG focus snapshot. -- **File system watcher**: selective watching of package.json, Cargo.toml, go.mod, .git/HEAD for external change detection. -- **Environment context**: one-time capture of appHost, remoteName, shell, uiKind, language. -- **Info toggles**: every sidebar card has a `?` button explaining how metrics are calculated (flow score formula, struggle signals, energy bar, optimal hours, dev loops, terminal impact, context cost). -- **Open NeuroSkill button**: launches native app from sidebar (cross-platform: `open -a` macOS, `start` Windows, `xdg-open` Linux). Also in command palette. -- **Command palette → sidebar**: `Show Brain Status`, `Today's Report`, `Am I Stuck?`, `Best Time to Code` now open sidebar and scroll to relevant section instead of showing toast notifications. - -### Server - -- **`terminal_commands` table**: shell command text, cwd, exit code, duration, auto-categorized (50+ patterns: build/test/run/git/docker/deploy/install/navigate/debug/network/other), EEG focus at start and end for delta calculation. -- **`dev_loops` table**: edit→build/test cycle tracking with iteration count, pass/fail rate, avg cycle time, focus trend (rising/falling/stable). -- **`zone_switches` table**: editor/terminal/panel transitions with EEG focus snapshot at moment of switch. -- **`layout_snapshots` table**: periodic tab/group/terminal counts from VS Code. -- **Event handler**: new match arms for `terminal_command_start`, `terminal_command_end`, `zone_switch`, `layout_snapshot` in `activity_vscode_events_impl()`. -- **Command categorizer**: `categorize_command()` with 50+ patterns across build, test, run, git, docker, deploy, install, navigate, debug, network. -- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category — shows how builds, tests, git, docker affect brain state. -- **`POST /v1/brain/context-cost`**: focus level at each zone transition type with switch counts. -- **`POST /v1/brain/dev-loops`**: edit-build-test cycle detection with iterations, pass rate, cycle time, focus trend. -- **`POST /v1/brain/terminal-commands`**: recent commands with exit codes, durations, categories, EEG correlation. -- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages". -- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl→infrastructure (0.8), deploy→deploying (0.85), git dominating→git_management (0.7), test→testing (0.85), debug→debugging (0.9). -- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end, `"switched to terminal"` on zone changes. -- **AI events table**: `ai_events` SQLite table tracking suggestion shown/accepted/rejected and chat sessions with source attribution. -- **OS-wide shell hooks**: `scripts/shell-hooks/` with preexec/precmd hooks for zsh, bash, fish, PowerShell. Sends every command to daemon via background curl. No delay to prompt. -- **Shell hook daemon endpoints**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to `~/.skill/shell-hooks/` and appends to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check. -- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). -- **`neuroskill brain` new subactions**: `terminal-impact`, `context-cost`, `dev-loops`. -- **`neuroskill activity` new subaction**: `terminal-commands`. -- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons, health indicators (green/yellow/red), recent commands preview, "How it works" documentation. -- **`neuroskill vscode` CLI**: auto-install extension to VS Code, VSCodium, or Cursor on macOS, Linux, and Windows. -- **Meeting nodes in search graph**: meetings appear as amber nodes in the interactive 3D search graph linked by `meeting_prox` edges. - -### Features - -- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable insights in one call. -- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — "your tests fail 45% more when focus is low." -- **Hourly productivity**: churn + undo rate + avg focus by hour of day — "you write 3x more bugs after 2pm." -- **Context switch recovery**: focus level at editor/terminal/panel transitions — "switching to terminal costs 4 focus points." -- **AI tool impact**: Claude/Pi focus delta vs baseline — "Claude conversations drop focus by 8 points." -- **Focus by language**: avg EEG focus + undo rate per programming language — "best focus in Rust, worst in CSS." -- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day. -- **Tool focus impact**: avg focus when using each command category (docker, git, deploy, etc.). - -### Refactor - -- **Conversations table + FTS5**: full-text search on all AI conversation messages (user/assistant/tool). Three search modes: FTS, fuzzy, structured. -- **EEG timeseries table**: periodic JSON snapshots of all brain metrics. Extensible — new metrics without schema changes. Join-at-query-time correlation with any event. -- **Embeddings store**: generic, multi-model. User prompts embedded via fastembed (local, no API credits). Can re-embed with different models. -- **Code context HNSW index**: separate from label index for code-specific semantic search. -- **Binary extraction**: terminal commands store raw binary name + args separately. Lazy categorization — 284 CLI tools recognized, rerun anytime. -- **EEG attachment**: live focus/mood from `latest_bands` injected at event storage time. -- **EEG timeseries worker**: writes full band powers to `eeg_timeseries` every 5s during recording. - -### UI - -- **Design tokens** (`tokens.css`): 30+ CSS custom properties — palette, surfaces, backgrounds, borders, semantic, typography. Zero inline rgba() in sidebar. -- **7 reusable Svelte components**: Card, MetricRow, Chevron, ProgressBar, Gauge, Badge, Callout. -- **Timestamp compliance**: 25 tests verifying UTC in data layer, local conversion only in UI. - -### Docs - -- VS Code extension design plan at `docs/vscode-extension.md`. -- `neuroskill-activity` skill documentation with 18 activity + 24 brain subcommands, terminal integration, shell hook reference, command categorization table. -- Updated `neuroskill-dnd` skill with grayscale mode. -- Updated `neuroskill/README.md` with terminal, brain awareness, and VS Code extension features. -- Updated `skills/SKILL.md` index with terminal tracking skill reference. diff --git a/changes/unreleased/feat-vscode-readme-rewrite.md b/changes/unreleased/feat-vscode-readme-rewrite.md deleted file mode 100644 index 0babf46f..00000000 --- a/changes/unreleased/feat-vscode-readme-rewrite.md +++ /dev/null @@ -1,5 +0,0 @@ -### Docs - -- **VS Code extension README rewritten for skeptical developers**: scientific framing throughout — every derived metric (focus score, flow detector, deep-work minute, struggle predictor, optimal hours, fatigue) now ships with its formula, hyperparameters, and a "what can go wrong" line. Added an explicit *What this is, and isn't* section, a *Validation status* table tracking each metric's evidence level (production / pilot / heuristic / descriptive-only), and a *Validation roadmap* describing the planned KSS / NASA-TLX / PVT / EEG-fatigue calibration channels. Replaced the marketing pain-points table with a per-API signal taxonomy and an architecture paragraph. -- **References section with verified citations**: nine entries with DOIs cross-checked via CrossRef and `doi.org` redirects — Csikszentmihalyi 1990 (flow), Klimesch 1999 (α/θ oscillations), Lubar 1991 (θ/β attention ratio), Barry et al. 2005 (caffeine confound), Newport 2016 (deep work), Roenneberg et al. 2003 (chronotypes), Hart & Staveland 1988 + Hart 2006 (NASA-TLX), Jap et al. 2009 (EEG fatigue index). Each reference linked back to the metric it grounds. -- **Repository metadata pointed at the right repo**: discovered `extensions/vscode/` is a git submodule of `vscode-neuroskill`, not a subdirectory of the monorepo. Reverted `repository.url`, `bugs.url`, `homepage`, `qna` to the submodule's actual repo so the marketplace links resolve. diff --git a/changes/unreleased/feat-vscode-readme-screenshots.md b/changes/unreleased/feat-vscode-readme-screenshots.md deleted file mode 100644 index 17eccd9c..00000000 --- a/changes/unreleased/feat-vscode-readme-screenshots.md +++ /dev/null @@ -1,9 +0,0 @@ -### UI - -- **Theme-aware sidebar screenshots in the README**: every screenshot now ships in two variants (`*-dark.png`, `*-light.png`) and is embedded via a `` element with `prefers-color-scheme` sources. GitHub serves the variant matching the reader's OS theme; the marketplace gets the dark fallback. Six states captured: in-flow, stuck, fatigued, low-focus / off-peak, daemon-disconnected, status-bar strip. -- **Sidebar mock library + Playwright generator**: HTML stubs in `extensions/vscode/media/preview/` render the sidebar webview offline (no daemon required). A shared `_styles.css` drives both themes via a `:root.light` override; `npm run screenshots` (Playwright) toggles `` between captures and writes 12 PNGs to `media/screenshots/`. -- **Marketplace README pipeline**: `scripts/build-marketplace-readme.mjs` rewrites every relative `media/...` path (including ``, which `vsce` doesn't touch) to absolute GitHub raw URLs at package time. The source `README.md` keeps relative paths for GitHub + offline viewing; the generated `.marketplace.readme.md` is what `vsce package --readme-path` ships. - -### Bugfixes - -- **Equal padding on screenshot edges**: viewport width was 360px while the body was 320px wide, leaving an asymmetric 40px right gutter. Matched viewport to body width so left and right gutters are now identical. diff --git a/changes/unreleased/feat-vscode-sidebar-light-mode.md b/changes/unreleased/feat-vscode-sidebar-light-mode.md deleted file mode 100644 index 936152bd..00000000 --- a/changes/unreleased/feat-vscode-sidebar-light-mode.md +++ /dev/null @@ -1,7 +0,0 @@ -### UI - -- **Sidebar webview now renders correctly in light themes** (`extensions/vscode/src/sidebar.ts`). Three theme-fragile spots fixed: - - `.metric-card` background switched from `--vscode-sideBar-background` (which is the same colour as the surrounding sidebar — cards collapsed into a faint border in light mode) to `--vscode-editorWidget-background`, with `--vscode-input-background` and a translucent grey as cascading fallbacks. - - `.ai-bar` track switched from `--vscode-panel-border` to `--vscode-progressBar-background` so the empty bar is visible against a white background. - - Row-divider opacity bumped from 0.08 to 0.18; added `:last-child { border-bottom: none }` on commit and AI-metric rows so the last row doesn't double up against the disclaimer footer. -- **State colours stay hard-coded** — red ring for stuck, green for flow, amber for warning. They're semantic, not chrome, and they read fine on both themes. diff --git a/changes/unreleased/feat-widget-a11y-i18n.md b/changes/unreleased/feat-widget-a11y-i18n.md deleted file mode 100644 index c9333273..00000000 --- a/changes/unreleased/feat-widget-a11y-i18n.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Widget accessibility and localization**. diff --git a/changes/unreleased/feat-widget-analysis.md b/changes/unreleased/feat-widget-analysis.md deleted file mode 100644 index 8a39433c..00000000 --- a/changes/unreleased/feat-widget-analysis.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Analysis widgets (Optimal Hours, Daily Report, Weekly Trend)**. diff --git a/changes/unreleased/feat-widget-biometrics.md b/changes/unreleased/feat-widget-biometrics.md deleted file mode 100644 index 55410d4f..00000000 --- a/changes/unreleased/feat-widget-biometrics.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Biometric widgets (Heart Rate, Cognitive Load, EEG Band Power, Sleep)**. diff --git a/changes/unreleased/feat-widget-brain-dashboard.md b/changes/unreleased/feat-widget-brain-dashboard.md deleted file mode 100644 index 0c167080..00000000 --- a/changes/unreleased/feat-widget-brain-dashboard.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Brain Dashboard widget (medium)**. diff --git a/changes/unreleased/feat-widget-calendar-mind.md b/changes/unreleased/feat-widget-calendar-mind.md deleted file mode 100644 index 3d9f6b99..00000000 --- a/changes/unreleased/feat-widget-calendar-mind.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Calendar Mind State widget (large)**. diff --git a/changes/unreleased/feat-widget-deep-links.md b/changes/unreleased/feat-widget-deep-links.md deleted file mode 100644 index 19dbca2c..00000000 --- a/changes/unreleased/feat-widget-deep-links.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Widget deep links (neuroskill:// URL scheme)**. diff --git a/changes/unreleased/feat-widget-dev-infra.md b/changes/unreleased/feat-widget-dev-infra.md deleted file mode 100644 index 92ed4ea0..00000000 --- a/changes/unreleased/feat-widget-dev-infra.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Widget development infrastructure**. diff --git a/changes/unreleased/feat-widget-focus-streak-session.md b/changes/unreleased/feat-widget-focus-streak-session.md deleted file mode 100644 index 54ab6bb9..00000000 --- a/changes/unreleased/feat-widget-focus-streak-session.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Core desktop widgets (Focus, Streak, Session, Break Timer)**. diff --git a/changes/unreleased/feat-widget-interactive.md b/changes/unreleased/feat-widget-interactive.md deleted file mode 100644 index 53de9a66..00000000 --- a/changes/unreleased/feat-widget-interactive.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Interactive widget buttons (macOS 14+)**. diff --git a/changes/unreleased/feat-widget-offline-cache.md b/changes/unreleased/feat-widget-offline-cache.md deleted file mode 100644 index c705a33d..00000000 --- a/changes/unreleased/feat-widget-offline-cache.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Widget offline data caching**. diff --git a/changes/unreleased/feat-widget-reload.md b/changes/unreleased/feat-widget-reload.md deleted file mode 100644 index 625a9ac9..00000000 --- a/changes/unreleased/feat-widget-reload.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Widget timeline reload on state changes**. diff --git a/changes/unreleased/feat-zuna-rs-mlx.md b/changes/unreleased/feat-zuna-rs-mlx.md deleted file mode 100644 index d71ce4ee..00000000 --- a/changes/unreleased/feat-zuna-rs-mlx.md +++ /dev/null @@ -1,9 +0,0 @@ -### Features - -- **zuna-rs 0.1.4**: updated from 0.1.3 with native MLX backend for EEG embedding inference. -- **`embed-zuna-mlx` feature**: new `ZunaMlxState` struct with `ZunaEncoder`. Adds `load_zuna_mlx()` and `encode_zuna_mlx()` functions matching the existing GPU/CPU variants. -- **Load priority**: when user selects Auto or MLX, encoder loading tries MLX first, then GPU f16, then GPU f32, then CPU. - -### i18n - -- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). diff --git a/changes/unreleased/fix-daemon-deadlocks-and-correctness.md b/changes/unreleased/fix-daemon-deadlocks-and-correctness.md deleted file mode 100644 index 10da19d0..00000000 --- a/changes/unreleased/fix-daemon-deadlocks-and-correctness.md +++ /dev/null @@ -1,9 +0,0 @@ -### Bugfixes - -- **Deadlock in daily-report endpoint**: `daily_brain_report` held the database mutex while calling `productivity_score`, which re-acquires the same mutex. Scoped the lock to drop before the nested call. -- **Deadlock in struggle-predict endpoint**: same pattern in `predict_struggle` — held mutex while calling `get_recent_files`. -- **`overall_focus` returned `-0.0`**: always wrapped in `Some` even with no EEG data. Now returns `null` when no focus samples exist. -- **`best_period` arbitrary without EEG**: picked the first SQL result when all `avg_focus` were `None`. Now returns empty string when no EEG data to compare. -- **`weekly_avg_deep_mins` returned `-0.0`**: divided by hardcoded 7 regardless of actual days. Now divides by actual day count, returns `0.0` when empty. -- **Notification text ugly without EEG**: daily report notification showed "Best: . Score: 25. Focus: 0." — now conditionally includes only available fields. -- **Brain endpoints return HTTP 200 on errors**: all 18 brain handlers silently returned `null` with 200 OK when the activity store was offline or a query failed. Refactored to use `run_query` helper that returns 503 (db_unavailable) or 500 (task_error) with structured `ApiError` JSON. diff --git a/changes/unreleased/fix-daemon-performance.md b/changes/unreleased/fix-daemon-performance.md deleted file mode 100644 index 8663c739..00000000 --- a/changes/unreleased/fix-daemon-performance.md +++ /dev/null @@ -1,8 +0,0 @@ -### Performance - -- **`open_readonly` for read-only handlers**: added `ActivityStore::open_readonly()` that skips 15+ ALTER TABLE migrations and opens in read-only mode. Switched 38+ HTTP handlers and 3 background tasks from `open` to `open_readonly`. -- **`PRAGMA busy_timeout=5000`**: added to `init_wal_pragmas` and `util::open_readonly` so all database connections (activity, labels, screenshots, EEG embeddings) wait up to 5 s on lock contention instead of failing immediately with SQLITE_BUSY. -- **Composite indexes**: added `(seen_at, file_path)` and `(seen_at, project)` indexes on `file_interactions` for queries that filter by time range and group by path or project. -- **Lock consolidation**: `productivity_score` reduced from 3 separate mutex acquisitions to 1 via internal `_q` query helpers. `weekly_digest` reduced from 12 to 4 locks. Introduced `daily_summary_q`, `context_switch_rate_q`, `get_focus_sessions_in_range_q` static methods that take `&Connection` directly. -- **`PRAGMA optimize` after pruning**: runs after hourly retention pruning to keep query planner statistics fresh. -- **Batch label lookup in interactive search**: replaced 15 per-epoch `get_labels_near` calls (each opening a new DB connection) with a single `get_labels_near_batch` call per text label, then in-memory filtering. Reduces search DB round-trips from ~28 to ~16. diff --git a/changes/unreleased/fix-daemon-reliability.md b/changes/unreleased/fix-daemon-reliability.md deleted file mode 100644 index 075d6b80..00000000 --- a/changes/unreleased/fix-daemon-reliability.md +++ /dev/null @@ -1,9 +0,0 @@ -### Bugfixes - -- **Worker thread panic recovery with auto-restart**: all 4 activity worker threads (poller, input monitor, file watcher, clipboard monitor) now wrapped in `catch_unwind` via `spawn_resilient`. On panic, the worker logs the error and restarts after 5 s with freshly cloned `AppState` and `Arc`. -- **osascript timeout**: all `osascript` calls (active window poll, secondary windows, clipboard monitor) now use a 3-second timeout via `run_osascript` helper. Previously, a hung app could block the poller thread indefinitely, silently killing all activity tracking. -- **Daily report catch-up on late startup**: if the daemon starts after 18:00 local, the daily brain report fires on the first tick instead of waiting up to 1 hour for the hourly check. -- **WAL checkpoint on shutdown**: daemon now runs `PRAGMA optimize` on the activity database during graceful shutdown, ensuring the WAL is checkpointed and the database is clean for next start. -- **Missing WAL pragmas on EEG embedding store**: `day_store.rs` opened connections without `init_wal_pragmas`, missing both WAL mode and `busy_timeout`. -- **Retention pruning for new tables**: added `prune_terminal_commands`, `prune_ai_events`, `prune_zone_switches`, `prune_layout_snapshots` — wired into the hourly maintenance cycle so these tables don't grow unbounded. -- **TOCTOU race in calibration settings**: concurrent profile CRUD could lose writes. Added `modify_settings_blocking()` helper that runs under a global mutex on tokio's blocking thread pool — serializes read-modify-write without blocking the async runtime. diff --git a/changes/unreleased/fix-eeg-pipeline.md b/changes/unreleased/fix-eeg-pipeline.md deleted file mode 100644 index 6f8f7ca2..00000000 --- a/changes/unreleased/fix-eeg-pipeline.md +++ /dev/null @@ -1,5 +0,0 @@ -### Bugfixes - -- **Duration-averaged EEG**: `FileSnapshot` now samples EEG focus/mood every 5 s (at each edit-chunk tick) and writes the average to `file_interactions` at finalize, replacing the single snapshot captured at file-switch time. Falls back to the initial snapshot if no samples were collected (`COALESCE`). -- **EEG mood/focus sample count mismatch**: both `FileSnapshot` and `build_focus_sessions` used a single counter for focus and mood samples, inflating mood averages when they arrived independently. Split into separate `focus_count` and `mood_count`. -- **EEG ghost data after disconnect**: `latest_bands` was not cleared when an EEG device disconnected, causing stale focus/mood values to persist in reports and the activity pipeline. Now cleared on all 3 disconnect paths (idle timeout, stream end, explicit disconnect). diff --git a/changes/unreleased/fix-frontend-leaks.md b/changes/unreleased/fix-frontend-leaks.md deleted file mode 100644 index 93a8abf6..00000000 --- a/changes/unreleased/fix-frontend-leaks.md +++ /dev/null @@ -1,9 +0,0 @@ -### Bugfixes - -- **InteractiveGraph3D event listener leaks**: `pointerdown` and `dblclick` listeners on the WebGL canvas were never removed in `onDestroy`. Extracted into named refs with cleanup. -- **UmapViewer3D event listener leaks**: 4 canvas event listeners (pointermove, pointerleave, pointerdown, pointerup) added as anonymous functions were never removed on unmount. Extracted into named module-level refs with removal in `onDestroy`. -- **ActivityTab brain polling leak**: `startBrainPolling()` was called on mount but `stopBrainPolling()` was never called on destroy, leaving a 30-second interval running after leaving the tab. -- **Brain polling stopped by peer component**: `stopBrainPolling()` unconditionally killed the interval even when other components still needed it. Added reference counting so polling only stops when the last consumer unmounts. -- **Memory leak in terminal_command_end labeling**: `Box::leak` was used to format non-zero exit codes in EEG auto-labeling, permanently leaking memory for every failed command. Replaced with owned `String`. -- **Screenshot encode failures untracked**: `encode_webp` failures (disk full, I/O error) silently continued without incrementing the `capture_errors` counter. Now counted so metrics reflect actual failures. -- **401 auto-retry**: daemon HTTP client now retries once with a fresh token on 401 (stale token after daemon restart) instead of failing immediately. diff --git a/changes/unreleased/fix-macos-libusb-static-link.md b/changes/unreleased/fix-macos-libusb-static-link.md deleted file mode 100644 index 8a250c88..00000000 --- a/changes/unreleased/fix-macos-libusb-static-link.md +++ /dev/null @@ -1,3 +0,0 @@ -### Build - -- **macOS: link libusb statically into `skill-daemon`**: vendored libusb via `rusb`'s `vendored` feature in `crates/skill-devices`. The `antneuro` USB driver pulls in `rusb` → `libusb1-sys`, which by default uses `pkg-config` to find a system libusb-1.0.dylib. On the macOS-26 release runner that resolved to Homebrew's `/opt/homebrew/opt/libusb/lib/libusb-1.0.0.dylib`, so end users without Homebrew hit a launch-time `dyld: Library not loaded` error. Cargo feature unification now flips `libusb1-sys/vendored`, which compiles libusb from source and links `libusb-vendored.a` into the binary — verified by 105 `_libusb_*` text symbols present in the release binary and zero libusb references in `otool -L`. diff --git a/changes/unreleased/fix-security.md b/changes/unreleased/fix-security.md deleted file mode 100644 index 3628e310..00000000 --- a/changes/unreleased/fix-security.md +++ /dev/null @@ -1,5 +0,0 @@ -### Bugfixes - -- **Path traversal in `delete_session`**: `csv_path` parameter was passed unsanitized to `fs::remove_file`. Now validates the path is within `skill_dir` via canonicalize + starts_with. -- **Timing-vulnerable token comparison**: default bearer token checks in auth middleware used `==` (variable-time). Switched to `constant_time_eq` for both in-memory and on-disk token paths. -- **Unbounded CSV memory in EXG loader**: loading a CSV with millions of rows would allocate unlimited RAM. Added a 4M row cap (~4.3 hours at 256 Hz). diff --git a/changes/unreleased/fix-timezone.md b/changes/unreleased/fix-timezone.md deleted file mode 100644 index ef571dfd..00000000 --- a/changes/unreleased/fix-timezone.md +++ /dev/null @@ -1,6 +0,0 @@ -### Bugfixes - -- **Timezone-wrong period classification**: `daily_brain_report`, `hourly_edit_heatmap`, and `optimal_hours` used `seen_at % 86400` (UTC hour) instead of local hour. Daily report now uses `seen_at - day_start`; heatmap and optimal-hours accept a timezone offset computed server-side. -- **UTC midnight in all clients**: search page, CLI (`cmdActivity`, `cmdBrain`), VS Code extension, and Swift widget all computed `day_start` as UTC midnight instead of local midnight. Fixed to use `Date.setHours(0,0,0,0)` (JS/TS) and `Calendar.startOfDay` (Swift). -- **Widget empty daily-report body**: `DaemonClient.fetchDailyReport()` sent an empty POST body; the endpoint requires `dayStart`. Now sends local midnight. -- **Widget UTC sleep/calendar endpoints**: `fetchSleep()` and `fetchCalendarEvents()` used `% 86400` UTC approximations. Now use `Calendar.current` for correct local time boundaries. diff --git a/crates/skill-iroh/Cargo.toml b/crates/skill-iroh/Cargo.toml index 8aca6bfe..5b76f8a3 100644 --- a/crates/skill-iroh/Cargo.toml +++ b/crates/skill-iroh/Cargo.toml @@ -11,6 +11,9 @@ thiserror = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } iroh = "0.97" +# Pin transitive pkcs8 to the rc that matches iroh's pre-release ed25519/ed25519-dalek; +# pkcs8 0.11.0 stable made KeyMalformed a tuple variant and broke them. +pkcs8 = "=0.11.0-rc.11" rand = "0.9" tokio = { version = "1", features = ["full"] } totp-rs = { version = "5.7", features = ["gen_secret", "otpauth", "qr"] } diff --git a/package.json b/package.json index c68afc55..6d48563f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.129", + "version": "0.0.130-rc.1", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 23c2c2ff..28156783 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.129" +version = "0.0.130-rc.1" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index be59e875..c2c61d3d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.129", + "version": "0.0.130-rc.1", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From 00beafc4085c524d1091f2ec156356d6229c3c94 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Wed, 29 Apr 2026 02:01:31 -0400 Subject: [PATCH 02/98] fix win/linux --- crates/skill-daemon/src/activity.rs | 1 + crates/skill-daemon/src/main.rs | 2 -- extensions/browser | 2 +- scripts/package-linux-system-bundles.sh | 5 +++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/skill-daemon/src/activity.rs b/crates/skill-daemon/src/activity.rs index 25718c17..bd51a87c 100644 --- a/crates/skill-daemon/src/activity.rs +++ b/crates/skill-daemon/src/activity.rs @@ -599,6 +599,7 @@ fn poll_active_window() -> Option { document_path: None, activated_at: unix_secs(), browser_title: None, + monitor_id: None, }) } } diff --git a/crates/skill-daemon/src/main.rs b/crates/skill-daemon/src/main.rs index 35389ad2..ab20b261 100644 --- a/crates/skill-daemon/src/main.rs +++ b/crates/skill-daemon/src/main.rs @@ -21,7 +21,6 @@ pub(crate) mod session_runner; mod tty; #[cfg(unix)] mod tty_backfill; -#[cfg(unix)] mod tty_embedder; #[cfg(unix)] mod tty_finalizer; @@ -150,7 +149,6 @@ async fn daemon_main() -> anyhow::Result<()> { tty_finalizer::spawn(state.clone()); // Fill in `terminal_outputs.embedding` for finalized rows. Runs every // 30 s, batches of 32, int8-quantised vectors. - #[cfg(unix)] tty_embedder::spawn(state.clone()); // Auto-refresh installed shell hooks so upgrades propagate fixes (e.g. the diff --git a/extensions/browser b/extensions/browser index ab5d4226..3fada5eb 160000 --- a/extensions/browser +++ b/extensions/browser @@ -1 +1 @@ -Subproject commit ab5d42266f852c21e68acab8d78b20643b6198b0 +Subproject commit 3fada5eb2559d457ba64aecebb8c8e4e3cb6620c diff --git a/scripts/package-linux-system-bundles.sh b/scripts/package-linux-system-bundles.sh index f9f8f319..5aec8192 100755 --- a/scripts/package-linux-system-bundles.sh +++ b/scripts/package-linux-system-bundles.sh @@ -67,6 +67,7 @@ if ! command -v rpmbuild >/dev/null 2>&1; then fi version="$(node -p "JSON.parse(require('fs').readFileSync('$ROOT_DIR/package.json','utf8')).version")" +rpm_version="${version//-/\~}" binary_path="$ROOT_DIR/src-tauri/target/$target/release/skill" resources_dir="$ROOT_DIR/src-tauri/resources" @@ -199,7 +200,7 @@ tar -czf "$rpm_top/SOURCES/neuroskill-root.tar.gz" -C "$work_root" "$(basename " cat > "$rpm_top/SPECS/neuroskill.spec" < - $version-1 +* $(date '+%a %b %d %Y') NeuroSkill CI - $rpm_version-1 - CI system-tool Linux package build EOF From f4821d9b51ae39d44a48d92b20e19e4d777cf7d0 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Wed, 29 Apr 2026 02:05:17 -0400 Subject: [PATCH 03/98] 0.0.130-rc.2 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 6 +++--- changes/releases/0.0.130-rc.2.md | 5 +++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 changes/releases/0.0.130-rc.2.md diff --git a/CHANGELOG.md b/CHANGELOG.md index aa6a44c7..87a7a4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5396,3 +5396,9 @@ The heatmap merges EEG data points with the closest timeline events to show whic - **Update llama-cpp-4 to 0.2.50**: upstream llama.cpp fixes including `common_*` symbol renames. - **Update kittentts to 0.4.1**: TTS engine update. - **Add grayscale 0.0.1**: macOS grayscale display control for DND mode. + +## [0.0.130-rc.2] — 2026-04-29 + +### Features + +- fix win/linux diff --git a/Cargo.lock b/Cargo.lock index 21fbd4b7..59c33f36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2357,9 +2357,9 @@ dependencies = [ [[package]] name = "coreaudio-rs" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16dd574a72a021b90c7656c474ea31d11a2f0366a8eff574186e761e0b9e3586" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ "bitflags 2.11.1", "libc", @@ -12159,7 +12159,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.1" +version = "0.0.130-rc.2" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/changes/releases/0.0.130-rc.2.md b/changes/releases/0.0.130-rc.2.md new file mode 100644 index 00000000..79eba5fa --- /dev/null +++ b/changes/releases/0.0.130-rc.2.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.2] — 2026-04-29 + +### Features + +- fix win/linux diff --git a/package.json b/package.json index 6d48563f..6bd0ac39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.1", + "version": "0.0.130-rc.2", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 28156783..0fa29920 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.1" +version = "0.0.130-rc.2" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c2c61d3d..e6141199 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.1", + "version": "0.0.130-rc.2", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From 4bd76fd86fa5587d76e3446e15a3b7d3c79b6fa9 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Wed, 29 Apr 2026 02:26:38 -0400 Subject: [PATCH 04/98] umap e2e --- .githooks/pre-commit | 18 ++++++++++++++++-- crates/skill-router/tests/umap_e2e_bench.rs | 2 ++ scripts/test-all.sh | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 11a89f19..21f6e494 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -30,8 +30,22 @@ fi # Run cargo fmt on staged Rust files if echo "$STAGED_FILES" | grep -q '\.rs$'; then echo "🦀 Running cargo fmt…" - cargo fmt - git add $( echo "$STAGED_FILES" | grep '\.rs$' ) + # GUI git clients (editors, Tower, etc.) launch hooks in a non-interactive + # shell that doesn't source ~/.zshrc, so rustup's ~/.cargo/bin is missing + # from PATH. Source the env file rustup ships, or fall back to the default + # install path. + if [ -f "$HOME/.cargo/env" ]; then + . "$HOME/.cargo/env" + elif [ -d "$HOME/.cargo/bin" ]; then + export PATH="$HOME/.cargo/bin:$PATH" + fi + if ! command -v cargo >/dev/null 2>&1; then + echo "⚠️ cargo not found on PATH; skipping cargo fmt." >&2 + echo " Install rustup (https://rustup.rs) or add ~/.cargo/bin to your shell's PATH." >&2 + else + cargo fmt + git add $( echo "$STAGED_FILES" | grep '\.rs$' ) + fi fi echo "✅ Pre-commit checks passed (basic validation only)." diff --git a/crates/skill-router/tests/umap_e2e_bench.rs b/crates/skill-router/tests/umap_e2e_bench.rs index 10c569b1..2c289184 100644 --- a/crates/skill-router/tests/umap_e2e_bench.rs +++ b/crates/skill-router/tests/umap_e2e_bench.rs @@ -161,6 +161,7 @@ fn umap_e2e_small() { /// Medium dataset (1000 points) — representative of a typical EEG session pair. #[test] +#[ignore = "slow benchmark; run with --include-ignored or via npm run test:mlx-e2e"] #[cfg(any(feature = "gpu", feature = "mlx"))] fn umap_e2e_medium() { let (skill_dir, a_start, a_end, b_start, b_end) = seed_synthetic_embeddings("medium", 500, 500); @@ -195,6 +196,7 @@ fn umap_e2e_medium() { /// Large dataset (5000 points) — stress test matching real-world cache sizes. #[test] +#[ignore = "slow benchmark; run with --include-ignored or via npm run test:mlx-e2e"] #[cfg(any(feature = "gpu", feature = "mlx"))] fn umap_e2e_large() { let (skill_dir, a_start, a_end, b_start, b_end) = seed_synthetic_embeddings("large", 2500, 2500); diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 92cca301..cfff6ecd 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -185,7 +185,7 @@ for suite in "${SUITES[@]}"; do ;; mlx-e2e) if [[ "$(uname -s)" == "Darwin" ]]; then - run_suite "UMAP MLX E2E" cargo test -p skill-router --features mlx -- umap_e2e --nocapture --test-threads=1 || { $STOP_ON_FAIL && break; } + run_suite "UMAP MLX E2E" cargo test -p skill-router --features mlx -- umap_e2e --nocapture --test-threads=1 --include-ignored || { $STOP_ON_FAIL && break; } run_suite "FFT MLX E2E" cargo test -p skill-eeg --features mlx -- fft_e2e --nocapture || { $STOP_ON_FAIL && break; } else skip_suite "MLX E2E" "requires macOS with Apple Silicon" From b575f1de090298c7e530fafd7050a8e3e8f9dddb Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Wed, 29 Apr 2026 16:07:10 -0400 Subject: [PATCH 05/98] fixed issues with the metrics, keychain access (3 -> 1 + on demand), windows CI --- Cargo.lock | 119 +------------ changes/unreleased.md | 8 + .../src/cmd_dispatch/system_cmds.rs | 9 +- .../src/routes/settings_device.rs | 29 +++- crates/skill-daemon/src/routes/settings_ui.rs | 11 +- crates/skill-daemon/src/scanner.rs | 8 +- .../skill-daemon/src/session/connect_ble.rs | 10 +- .../skill-daemon/src/session/connect_wired.rs | 26 +-- crates/skill-daemon/src/session/shared.rs | 32 +--- crates/skill-devices/src/lib.rs | 163 ++++++++++++++++++ crates/skill-eeg/src/eeg_bands.rs | 15 ++ crates/skill-exg/Cargo.toml | 1 + crates/skill-exg/src/lib.rs | 150 ++++++++++++++-- crates/skill-headless/Cargo.toml | 4 +- crates/skill-settings/src/keychain.rs | 135 +++++++++++++-- crates/skill-settings/src/lib.rs | 24 ++- scripts/create-windows-nsis.ps1 | 17 +- scripts/release.js | 38 +++- scripts/smoke-test.sh | 149 +++++++++++++--- scripts/test-all.sh | 9 +- src-tauri/src/helpers.rs | 10 +- src-tauri/src/setup.rs | 3 +- src-tauri/src/state.rs | 4 - src-tauri/src/window_cmds.rs | 31 +++- test.ts | 17 +- 25 files changed, 729 insertions(+), 293 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59c33f36..942526bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8887,16 +8887,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-core-location" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-core-media" version = "0.3.2" @@ -9106,27 +9096,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.1", - "block2 0.6.2", "objc2 0.6.4", - "objc2-cloud-kit", - "objc2-core-data", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-location", - "objc2-core-text", - "objc2-foundation 0.3.2", - "objc2-quartz-core", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" -dependencies = [ - "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -12482,6 +12453,7 @@ dependencies = [ "serde_json", "skill-constants", "skill-data", + "skill-devices", "skill-eeg", "ureq 3.3.0", ] @@ -12508,9 +12480,9 @@ dependencies = [ "http 1.4.0", "serde", "serde_json", - "tao 0.35.0", + "tao", "thiserror 2.0.18", - "wry 0.55.0", + "wry", ] [[package]] @@ -13468,43 +13440,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "tao" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159" -dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "core-foundation 0.10.1", - "core-graphics", - "crossbeam-channel", - "dispatch2", - "dlopen2 0.8.2", - "dpi", - "gdkwayland-sys", - "gtk", - "jni 0.21.1", - "libc", - "log", - "ndk", - "ndk-sys", - "objc2 0.6.4", - "objc2-app-kit", - "objc2-foundation 0.3.2", - "objc2-ui-kit", - "once_cell", - "parking_lot", - "percent-encoding", - "raw-window-handle", - "tao-macros", - "unicode-segmentation", - "url", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", -] - [[package]] name = "tao-macros" version = "0.1.3" @@ -13826,14 +13761,14 @@ dependencies = [ "percent-encoding", "raw-window-handle", "softbuffer", - "tao 0.34.8", + "tao", "tauri-runtime", "tauri-utils", "url", "webkit2gtk", "webview2-com", "windows 0.61.3", - "wry 0.54.4", + "wry", ] [[package]] @@ -16902,50 +16837,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "wry" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429" -dependencies = [ - "base64 0.22.1", - "block2 0.6.2", - "cookie", - "crossbeam-channel", - "dirs", - "dom_query", - "dpi", - "dunce", - "gdkx11", - "gtk", - "http 1.4.0", - "javascriptcore-rs", - "jni 0.21.1", - "libc", - "ndk", - "objc2 0.6.4", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-ui-kit", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "sha2 0.10.9", - "soup3", - "tao-macros", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - [[package]] name = "ws_stream_wasm" version = "0.7.5" diff --git a/changes/unreleased.md b/changes/unreleased.md index 2f16bedc..d6cfc1b4 100644 --- a/changes/unreleased.md +++ b/changes/unreleased.md @@ -3,3 +3,11 @@ ### UI - Add "Force Restart" button to the engine status hover panel on the dashboard + +### Build + +- Align `skill-headless` to `wry 0.54` / `tao 0.34` so it matches the versions `tauri-runtime-wry 2.10.1` already pulls in. Previously the workspace built two copies of wry/tao (0.54.4 + 0.55.0, 0.34.8 + 0.35.0) because `skill-headless` pinned the newer pair. Single resolved version now, smaller binary, no functional change. + +### Security + +- **Lazy keychain access**: the macOS keychain is no longer read at app/daemon startup. Previously, `load_settings()` eagerly fetched all eight stored secrets (api_token, Emotiv, IDUN, Oura, Neurosity), and three separate processes (Tauri shell, daemon `state::new`, daemon `main`) each ran it during boot. On a fresh build the code signature changes, so the OS prompted up to three times before the user could see the app. Secrets are now fetched on demand from the keychain only when the user actually opens device settings, connects a device, or runs a sync — so at most one prompt appears, gated on user intent. Tauri's `AppState` no longer caches `api_token` / `device_api_config`; the daemon's route handlers (`set_device_api_config`, `set_api_token`) write secrets directly to the keychain and skip empty values to avoid clobbering existing entries on partial saves. diff --git a/crates/skill-daemon/src/cmd_dispatch/system_cmds.rs b/crates/skill-daemon/src/cmd_dispatch/system_cmds.rs index 8d44b09b..84130d4b 100644 --- a/crates/skill-daemon/src/cmd_dispatch/system_cmds.rs +++ b/crates/skill-daemon/src/cmd_dispatch/system_cmds.rs @@ -169,10 +169,8 @@ pub(super) async fn cmd_health_metric_types(state: &AppState) -> Result Result { - let skill_dir = skill_dir(state); - let settings = skill_settings::load_settings(&skill_dir); - let has_token = !settings.device_api.oura_access_token.is_empty(); +pub(super) async fn cmd_oura_status(_state: &AppState) -> Result { + let has_token = !skill_settings::keychain::get_oura_access_token().is_empty(); Ok(json!({ "connected": has_token, "has_token": has_token, @@ -183,8 +181,7 @@ pub(super) async fn cmd_oura_sync(state: &AppState, msg: &Value) -> Result) -> Json { let c = load_user_settings(&state).device_api; + let (emotiv_client_id, emotiv_client_secret) = skill_settings::keychain::get_emotiv_credentials(); + let idun_api_token = skill_settings::keychain::get_idun_api_token(); + let oura_access_token = skill_settings::keychain::get_oura_access_token(); + let (neurosity_email, neurosity_password, neurosity_device_id) = + skill_settings::keychain::get_neurosity_credentials(); Json(serde_json::json!({ - "emotiv_client_id": c.emotiv_client_id, - "emotiv_client_secret": c.emotiv_client_secret, - "idun_api_token": c.idun_api_token, - "oura_access_token": c.oura_access_token, - "neurosity_email": c.neurosity_email, - "neurosity_password": c.neurosity_password, - "neurosity_device_id": c.neurosity_device_id, + "emotiv_client_id": emotiv_client_id, + "emotiv_client_secret": emotiv_client_secret, + "idun_api_token": idun_api_token, + "oura_access_token": oura_access_token, + "neurosity_email": neurosity_email, + "neurosity_password": neurosity_password, + "neurosity_device_id": neurosity_device_id, "brainmaster_model": c.brainmaster_model, })) } @@ -138,6 +143,16 @@ pub(crate) async fn set_device_api_config( let mut settings = load_user_settings(&state); settings.device_api = config.clone(); save_user_settings(&state, &settings); + skill_settings::keychain::save_device_api_secrets(&skill_settings::keychain::Secrets { + api_token: String::new(), + emotiv_client_id: config.emotiv_client_id.clone(), + emotiv_client_secret: config.emotiv_client_secret.clone(), + idun_api_token: config.idun_api_token.clone(), + oura_access_token: config.oura_access_token.clone(), + neurosity_email: config.neurosity_email.clone(), + neurosity_password: config.neurosity_password.clone(), + neurosity_device_id: config.neurosity_device_id.clone(), + }); if let Ok(mut cortex) = state.scanner_cortex_config.lock() { cortex.emotiv_client_id = config.emotiv_client_id; cortex.emotiv_client_secret = config.emotiv_client_secret; diff --git a/crates/skill-daemon/src/routes/settings_ui.rs b/crates/skill-daemon/src/routes/settings_ui.rs index 58943aba..70720858 100644 --- a/crates/skill-daemon/src/routes/settings_ui.rs +++ b/crates/skill-daemon/src/routes/settings_ui.rs @@ -235,18 +235,15 @@ pub(crate) async fn test_location() -> Json { Json(v) } -pub(crate) async fn get_api_token(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.api_token})) +pub(crate) async fn get_api_token(State(_state): State) -> Json { + Json(serde_json::json!({"value": skill_settings::keychain::get_api_token()})) } pub(crate) async fn set_api_token( - State(state): State, + State(_state): State, Json(req): Json, ) -> Json { - let mut settings = load_user_settings(&state); - settings.api_token = req.value; - save_user_settings(&state, &settings); + skill_settings::keychain::set_api_token(&req.value); Json(serde_json::json!({"ok": true})) } diff --git a/crates/skill-daemon/src/scanner.rs b/crates/skill-daemon/src/scanner.rs index a0bd4332..f2cd077d 100644 --- a/crates/skill-daemon/src/scanner.rs +++ b/crates/skill-daemon/src/scanner.rs @@ -601,12 +601,8 @@ pub(crate) fn detect_manual_device_hints(state: &AppState) -> Vec) -> anyhow::Resul // ── IDUN Guardian (BLE) ────────────────────────────────────────────────────── pub(super) async fn connect_idun( - state: &AppState, + _state: &AppState, paired_name: Option, ) -> anyhow::Result> { use skill_devices::idun::prelude::*; use skill_devices::session::idun::IdunAdapter; - let api_token = { - let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - skill_settings::load_settings(&skill_dir) - .device_api - .idun_api_token - .clone() - }; + let api_token = skill_settings::keychain::get_idun_api_token(); info!("connecting to IDUN Guardian…"); let config = GuardianClientConfig { diff --git a/crates/skill-daemon/src/session/connect_wired.rs b/crates/skill-daemon/src/session/connect_wired.rs index 7f7a9b3c..e7182be7 100644 --- a/crates/skill-daemon/src/session/connect_wired.rs +++ b/crates/skill-daemon/src/session/connect_wired.rs @@ -538,33 +538,32 @@ impl skill_devices::session::DeviceAdapter for NeuroSkyAdapter { // ── Neurosity Crown/Notion (Cloud API) ───────────────────────────────────── -pub(super) async fn connect_neurosity(state: &AppState, target: &str) -> anyhow::Result> { +pub(super) async fn connect_neurosity(_state: &AppState, target: &str) -> anyhow::Result> { use neurosity::prelude::*; let requested_device_id = target.strip_prefix("neurosity:").unwrap_or("").trim().to_string(); let (device_id, email, password) = { - let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let settings = skill_settings::load_settings(&skill_dir); + let (kc_email, kc_password, kc_device_id) = skill_settings::keychain::get_neurosity_credentials(); let device_id = if requested_device_id.is_empty() { - settings.device_api.neurosity_device_id.clone() + kc_device_id } else { requested_device_id }; - let email = if settings.device_api.neurosity_email.trim().is_empty() { + let email = if kc_email.trim().is_empty() { std::env::var("SKILL_NEUROSITY_EMAIL") .or_else(|_| std::env::var("NEUROSITY_EMAIL")) .unwrap_or_default() } else { - settings.device_api.neurosity_email.clone() + kc_email }; - let password = if settings.device_api.neurosity_password.trim().is_empty() { + let password = if kc_password.trim().is_empty() { std::env::var("SKILL_NEUROSITY_PASSWORD") .or_else(|_| std::env::var("NEUROSITY_PASSWORD")) .unwrap_or_default() } else { - settings.device_api.neurosity_password.clone() + kc_password }; (device_id, email, password) @@ -913,18 +912,11 @@ pub(super) async fn connect_antneuro(state: &AppState, target: &str) -> anyhow:: // ── Emotiv (Cortex WebSocket API) ──────────────────────────────────────────── -pub(super) async fn connect_emotiv(state: &AppState) -> anyhow::Result> { +pub(super) async fn connect_emotiv(_state: &AppState) -> anyhow::Result> { use skill_devices::emotiv::prelude::*; use skill_devices::session::emotiv::EmotivAdapter; - let (client_id, client_secret) = { - let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let settings = skill_settings::load_settings(&skill_dir); - ( - settings.device_api.emotiv_client_id.clone(), - settings.device_api.emotiv_client_secret.clone(), - ) - }; + let (client_id, client_secret) = skill_settings::keychain::get_emotiv_credentials(); if client_id.trim().is_empty() || client_secret.trim().is_empty() { anyhow::bail!("Emotiv client_id/client_secret not configured in Settings → Device API"); diff --git a/crates/skill-daemon/src/session/shared.rs b/crates/skill-daemon/src/session/shared.rs index e33cf9d2..b63ef9b6 100644 --- a/crates/skill-daemon/src/session/shared.rs +++ b/crates/skill-daemon/src/session/shared.rs @@ -66,14 +66,17 @@ pub fn broadcast_event(tx: &broadcast::Sender, event_type: &str, // ── Band snapshot enrichment ────────────────────────────────────────────────── -/// Enrich a `BandSnapshot` with composite scores (focus, relaxation, engagement, -/// artifacts) and return the result as JSON. +/// Enrich a `BandSnapshot` with composite scores and return the result as JSON. +/// +/// All composite-score math (engagement / relaxation / focus / meditation / +/// cognitive_load / drowsiness) lives in `skill_devices` and is written +/// directly onto the snapshot fields by `skill_devices::enrich_band_snapshot`. +/// This wrapper only adds the daemon-side context (artifacts, GPU stats) and +/// serializes — every consumer reads identical values from the snapshot. pub fn enrich_band_snapshot( snap: &mut skill_eeg::eeg_bands::BandSnapshot, artifacts: Option<&skill_eeg::artifact_detection::ArtifactMetrics>, ) -> serde_json::Value { - // Use skill_devices::enrich_band_snapshot for the full enrichment - // (blink_count, blink_rate, head_pose, composite scores). let ctx = skill_devices::SnapshotContext { ppg: None, artifacts: artifacts.copied(), @@ -82,26 +85,7 @@ pub fn enrich_band_snapshot( gpu: skill_data::gpu_stats::read(), }; skill_devices::enrich_band_snapshot(snap, &ctx); - - // Add composite scores derived from band power. - let mut val = serde_json::to_value(&*snap).unwrap_or_default(); - if let Some(obj) = val.as_object_mut() { - let engage_raw = skill_devices::compute_engagement_raw(snap); - let focus = skill_devices::focus_score(engage_raw); - let nch = snap.channels.len().max(1) as f64; - let avg_alpha = snap.channels.iter().map(|c| c.rel_alpha as f64).sum::() / nch; - let avg_beta = snap.channels.iter().map(|c| c.rel_beta as f64).sum::() / nch; - let relaxation = if (avg_alpha + avg_beta) > 0.0 { - (avg_alpha / (avg_alpha + avg_beta)) * 100.0 - } else { - 0.0 - }; - let engagement = 100.0 / (1.0 + (-2.0 * (engage_raw as f64 - 0.8)).exp()); - obj.insert("focus".into(), serde_json::json!(focus)); - obj.insert("relaxation".into(), serde_json::json!(relaxation)); - obj.insert("engagement".into(), serde_json::json!(engagement)); - } - val + serde_json::to_value(&*snap).unwrap_or_default() } // ── Session metadata ────────────────────────────────────────────────────────── diff --git a/crates/skill-devices/src/lib.rs b/crates/skill-devices/src/lib.rs index d37e4998..186315f9 100644 --- a/crates/skill-devices/src/lib.rs +++ b/crates/skill-devices/src/lib.rs @@ -90,6 +90,14 @@ pub fn enrich_band_snapshot(snap: &mut BandSnapshot, ctx: &SnapshotContext) { let drowsiness = compute_drowsiness(snap); snap.drowsiness = Some((drowsiness * 10.0).round() / 10.0); + // Canonical engagement / relaxation / focus — single source of truth. + let engagement = compute_engagement(snap); + snap.engagement = Some((engagement * 10.0).round() / 10.0); + let relaxation = compute_relaxation(snap); + snap.relaxation = Some((relaxation * 10.0).round() / 10.0); + let focus = compute_focus(snap); + snap.focus = Some((focus * 10.0).round() / 10.0); + // GPU stats if let Some(ref gpu) = ctx.gpu { snap.gpu_overall = Some(gpu.overall as f64); @@ -219,6 +227,67 @@ pub fn focus_score(engagement_raw: f32) -> f64 { (100.0_f32 / (1.0 + (-2.0 * (engagement_raw - 0.8)).exp())) as f64 } +// ── Canonical engagement / relaxation / focus ──────────────────────────────── +// +// Single source of truth for these three composite scores. Every consumer +// (live `latest_bands`, persisted `metrics_json`, time-series cache, websocket +// broadcasts, frontend, VS Code extension, widgets) reads identical values via +// `enrich_band_snapshot` populating `snap.engagement` / `snap.relaxation` / +// `snap.focus`. Do not re-derive these in caller code — read the snapshot. + +/// Sigmoid (0,∞) → (0,100): `100 / (1 + exp(−k·(x − mid)))`. +/// +/// Shared by both `compute_engagement` and `compute_relaxation`. Identical +/// shape to `EpochMetrics::sigmoid100` — duplicated only to keep this crate +/// dependency-free of `skill-exg`. +fn sigmoid_0_100(x: f32, k: f32, mid: f32) -> f64 { + (100.0_f32 / (1.0 + (-k * (x - mid)).exp())) as f64 +} + +/// Engagement score (0–100) — final, sigmoided. +/// +/// Per-channel β / (α + θ), with a `0.5` neutral fallback for channels whose +/// (α + θ) collapses to zero (poor electrode contact, missing band power, …). +/// Without the fallback, low-signal channels would drag the average toward +/// zero and pin the score at a constant ~16.8 — the historical "engagement +/// doesn't move" bug. +pub fn compute_engagement(snap: &BandSnapshot) -> f64 { + sigmoid_0_100(compute_engagement_raw(snap), 2.0, 0.8) +} + +/// Relaxation score (0–100) — final, sigmoided. +/// +/// Per-channel α / (β + θ), with the same `0.5` neutral fallback for +/// degenerate channels. Theta is in the denominator (matches Putman 2010 / +/// Angelidis 2016) — earlier sites that used `α / (α + β)` are deprecated. +pub fn compute_relaxation(snap: &BandSnapshot) -> f64 { + if snap.channels.is_empty() { + return sigmoid_0_100(0.5, 2.5, 1.0); + } + let n = snap.channels.len() as f32; + let raw: f32 = snap + .channels + .iter() + .map(|ch| { + let d = ch.rel_beta + ch.rel_theta; + if d > 1e-6 { + ch.rel_alpha / d + } else { + 0.5 + } + }) + .sum::() + / n; + sigmoid_0_100(raw, 2.5, 1.0) +} + +/// Focus score (0–100). Currently the same as engagement; kept distinct so +/// the UI can surface a "focus" label and so the formula can diverge later +/// without another rename across consumers. +pub fn compute_focus(snap: &BandSnapshot) -> f64 { + focus_score(compute_engagement_raw(snap)) +} + // ── Battery EMA ─────────────────────────────────────────────────────────────── /// Exponential moving average for battery level with low-battery alerts. @@ -534,6 +603,9 @@ mod tests { meditation: None, cognitive_load: None, drowsiness: None, + engagement: None, + relaxation: None, + focus: None, temperature_raw: None, gpu_overall: None, gpu_render: None, @@ -572,6 +644,97 @@ mod tests { assert!(focus_score(1.0) <= 100.0); } + /// End-to-end sanity: enriching a snapshot must populate engagement / + /// relaxation / focus, and the values must equal the canonical compute_* + /// functions. Locks down the single-source-of-truth contract so any future + /// regression where a caller computes its own metric will fail loudly. + #[test] + fn enrich_populates_canonical_engagement_relaxation_focus() { + let mut snap = test_snap(); + let ctx = SnapshotContext { + ppg: None, + artifacts: None, + head_pose: None, + temperature_raw: 0, + gpu: None, + }; + // Canonical values, computed *before* enrichment so the snapshot is + // unmutated — proves the enrich path doesn't have hidden state. + let want_engagement = compute_engagement(&snap); + let want_relaxation = compute_relaxation(&snap); + let want_focus = compute_focus(&snap); + + enrich_band_snapshot(&mut snap, &ctx); + + let got_e = snap.engagement.expect("engagement populated"); + let got_r = snap.relaxation.expect("relaxation populated"); + let got_f = snap.focus.expect("focus populated"); + + // Snapshot values are rounded to 1 decimal; canonical values are not. + assert!( + (got_e - want_engagement).abs() < 0.05, + "engagement mismatch: {got_e} vs {want_engagement}" + ); + assert!( + (got_r - want_relaxation).abs() < 0.05, + "relaxation mismatch: {got_r} vs {want_relaxation}" + ); + assert!( + (got_f - want_focus).abs() < 0.05, + "focus mismatch: {got_f} vs {want_focus}" + ); + + // Sanity: scores are 0–100. + for (name, v) in [("engagement", got_e), ("relaxation", got_r), ("focus", got_f)] { + assert!((0.0..=100.0).contains(&v), "{name}={v} out of range"); + } + } + + /// Reproduces the stuck-engagement failure mode: channels with + /// `rel_alpha + rel_theta ≈ 0`. Pre-refactor this drove engagement to a + /// constant ~16.8. Post-refactor the per-channel 0.5 fallback keeps the + /// score at the neutral midpoint and — critically — makes it *move* when + /// other channels recover signal. + #[test] + fn engagement_does_not_collapse_on_zero_alpha_theta() { + // All channels: alpha=0, theta=0, beta>0. Pre-refactor: stuck-low ~16.8. + let mut snap = test_snap(); + for ch in &mut snap.channels { + ch.alpha = 0.0; + ch.theta = 0.0; + ch.beta = 1.0; + ch.rel_alpha = 0.0; + ch.rel_theta = 0.0; + ch.rel_beta = 1.0; + } + + let stuck_low = compute_engagement(&snap); + // Should be at the neutral-fallback midpoint, not pinned at ~16.8. + assert!( + stuck_low > 30.0, + "engagement collapsed to {stuck_low} on zero-α+θ channels" + ); + + // Now flip one channel to a high-engagement profile and verify the + // score *moves* — the original bug was that it didn't. + snap.channels[0].rel_alpha = 0.10; + snap.channels[0].rel_theta = 0.10; + // Same rel_beta=1.0 → β/(α+θ) = 5 → strong engagement signal. + let moved = compute_engagement(&snap); + assert!( + moved > stuck_low + 1.0, + "engagement did not move: {stuck_low} -> {moved}" + ); + } + + /// Storage-side parity: `EpochMetrics::from_snapshot` (in `skill-exg`) + /// must produce the same engagement/relaxation as the canonical compute + /// functions. We can't import skill-exg here without a dep cycle, so this + /// test lives in `skill-exg`'s own test suite — see + /// `crates/skill-exg/src/lib.rs::tests::epoch_metrics_match_canonical`. + #[test] + fn _see_epoch_metrics_match_canonical_in_skill_exg() {} + #[test] fn battery_ema_first_reading() { let mut b = BatteryEma::new(0.1); diff --git a/crates/skill-eeg/src/eeg_bands.rs b/crates/skill-eeg/src/eeg_bands.rs index 5c9805af..283aa922 100644 --- a/crates/skill-eeg/src/eeg_bands.rs +++ b/crates/skill-eeg/src/eeg_bands.rs @@ -289,6 +289,18 @@ pub struct BandSnapshot { /// Drowsiness score (0–100). High TAR + alpha spindles. #[serde(skip_serializing_if = "Option::is_none")] pub drowsiness: Option, + /// Engagement score (0–100). Per-channel β / (α + θ), per-channel `0.5` + /// fallback for low-signal channels, then sigmoid. + #[serde(skip_serializing_if = "Option::is_none")] + pub engagement: Option, + /// Relaxation score (0–100). Per-channel α / (β + θ), per-channel `0.5` + /// fallback for low-signal channels, then sigmoid. + #[serde(skip_serializing_if = "Option::is_none")] + pub relaxation: Option, + /// Focus score (0–100). Currently identical to `engagement`; kept as a + /// distinct field for UI semantics and future divergence. + #[serde(skip_serializing_if = "Option::is_none")] + pub focus: Option, // ── Device telemetry ───────────────────────────────────────────────────── /// Raw temperature ADC value from headset (Classic firmware only). @@ -1001,6 +1013,9 @@ impl BandAnalyzer { meditation: None, cognitive_load: None, drowsiness: None, + engagement: None, + relaxation: None, + focus: None, temperature_raw: None, gpu_overall: None, gpu_render: None, diff --git a/crates/skill-exg/Cargo.toml b/crates/skill-exg/Cargo.toml index 96dc4495..d0f8f538 100644 --- a/crates/skill-exg/Cargo.toml +++ b/crates/skill-exg/Cargo.toml @@ -17,6 +17,7 @@ anyhow = { workspace = true } skill-constants = { path = "../skill-constants" } skill-eeg = { path = "../skill-eeg" } skill-data = { path = "../skill-data" } +skill-devices = { path = "../skill-devices" } serde = { version = "1", features = ["derive"] } serde_json = "1" hf-hub = "0.5" diff --git a/crates/skill-exg/src/lib.rs b/crates/skill-exg/src/lib.rs index bf504de1..840df519 100644 --- a/crates/skill-exg/src/lib.rs +++ b/crates/skill-exg/src/lib.rs @@ -776,6 +776,10 @@ pub struct EpochMetrics { impl EpochMetrics { /// Derive metrics from a `BandSnapshot` by averaging across all channels. + /// + /// Engagement and relaxation delegate to `skill_devices::compute_engagement` + /// / `compute_relaxation` — the single source of truth shared with the live + /// `latest_bands` path. Storing here is fine; *computing* here is not. pub fn from_snapshot(snap: &BandSnapshot) -> Self { let n = snap.channels.len() as f32; if n < 1.0 { @@ -788,8 +792,6 @@ impl EpochMetrics { let mut rb = 0.0f32; let mut rg = 0.0f32; let mut rhg = 0.0f32; - let mut sum_relax = 0.0f32; - let mut sum_engage = 0.0f32; for ch in &snap.channels { rd += ch.rel_delta; @@ -798,17 +800,6 @@ impl EpochMetrics { rb += ch.rel_beta; rg += ch.rel_gamma; rhg += ch.rel_high_gamma; - let a = ch.rel_alpha; - let b = ch.rel_beta; - let t = ch.rel_theta; - let d1 = a + t; - let d2 = b + t; - if d2 > 1e-6 { - sum_relax += a / d2; - } - if d1 > 1e-6 { - sum_engage += b / d1; - } } rd /= n; rt /= n; @@ -832,8 +823,8 @@ impl EpochMetrics { rel_beta: rb, rel_gamma: rg, rel_high_gamma: rhg, - relaxation: Self::sigmoid100(sum_relax / n, 2.5, 1.0), - engagement: Self::sigmoid100(sum_engage / n, 2.0, 0.8), + relaxation: skill_devices::compute_relaxation(snap) as f32, + engagement: skill_devices::compute_engagement(snap) as f32, faa, tar: snap.tar, bar: snap.bar, @@ -1136,6 +1127,135 @@ mod tests { assert_eq!(back.rel_delta, m.rel_delta); } + /// Closes the single-source-of-truth loop: storage path + /// (`EpochMetrics::from_snapshot`) and live path + /// (`skill_devices::compute_engagement` / `compute_relaxation`) must + /// agree on the same `BandSnapshot`. Pre-refactor they diverged — this + /// test would have caught the stuck-engagement bug. + #[test] + fn epoch_metrics_match_canonical_compute() { + use skill_eeg::eeg_bands::{BandPowers, BandSnapshot}; + + let ch = BandPowers { + channel: "AF7".into(), + delta: 5.0, + theta: 3.0, + alpha: 4.0, + beta: 6.0, + gamma: 1.0, + high_gamma: 0.5, + rel_delta: 0.25, + rel_theta: 0.15, + rel_alpha: 0.20, + rel_beta: 0.30, + rel_gamma: 0.05, + rel_high_gamma: 0.05, + dominant: "beta".into(), + dominant_symbol: "β".into(), + dominant_color: "#22c55e".into(), + }; + let mut snap = BandSnapshot { + timestamp: 0.0, + channels: vec![ch.clone(), ch.clone(), ch.clone(), ch], + faa: 0.0, + tar: 0.5, + bar: 0.4, + dtr: 1.2, + pse: 0.7, + apf: 10.0, + bps: -1.5, + snr: 12.0, + coherence: 0.5, + mu_suppression: 0.1, + mood: 60.0, + tbr: 0.8, + sef95: 22.0, + spectral_centroid: 15.0, + hjorth_activity: 0.1, + hjorth_mobility: 0.2, + hjorth_complexity: 0.3, + permutation_entropy: 0.6, + higuchi_fd: 1.5, + dfa_exponent: 0.7, + sample_entropy: 0.4, + pac_theta_gamma: 0.1, + laterality_index: 0.05, + headache_index: 10.0, + migraine_index: 5.0, + consciousness_lzc: 50.0, + consciousness_wakefulness: 70.0, + consciousness_integration: 60.0, + hr: None, + rmssd: None, + sdnn: None, + pnn50: None, + lf_hf_ratio: None, + respiratory_rate: None, + spo2_estimate: None, + perfusion_index: None, + stress_index: None, + blink_count: None, + blink_rate: None, + head_pitch: None, + head_roll: None, + stillness: None, + nod_count: None, + shake_count: None, + meditation: None, + cognitive_load: None, + drowsiness: None, + engagement: None, + relaxation: None, + focus: None, + temperature_raw: None, + gpu_overall: None, + gpu_render: None, + gpu_tiler: None, + rel_delta: 0.25, + rel_theta: 0.15, + rel_alpha: 0.20, + rel_beta: 0.30, + rel_gamma: 0.05, + }; + + let metrics = EpochMetrics::from_snapshot(&snap); + let canonical_e = skill_devices::compute_engagement(&snap) as f32; + let canonical_r = skill_devices::compute_relaxation(&snap) as f32; + + assert!( + (metrics.engagement - canonical_e).abs() < 0.001, + "EpochMetrics.engagement={} diverges from canonical={canonical_e}", + metrics.engagement, + ); + assert!( + (metrics.relaxation - canonical_r).abs() < 0.001, + "EpochMetrics.relaxation={} diverges from canonical={canonical_r}", + metrics.relaxation, + ); + + // And confirm enrich_band_snapshot puts the same value on the wire format. + skill_devices::enrich_band_snapshot( + &mut snap, + &skill_devices::SnapshotContext { + ppg: None, + artifacts: None, + head_pose: None, + temperature_raw: 0, + gpu: None, + }, + ); + let on_snapshot_e = snap.engagement.unwrap(); + let on_snapshot_r = snap.relaxation.unwrap(); + assert!( + (on_snapshot_e as f32 - canonical_e).abs() < 0.05, + "snapshot.engagement={on_snapshot_e} diverges from canonical={canonical_e}", + ); + assert!( + (on_snapshot_r as f32 - canonical_r).abs() < 0.05, + "snapshot.relaxation={on_snapshot_r} diverges from canonical={canonical_r}", + ); + } + // ── validate_safetensors ───────────────────────────────────────────── #[test] diff --git a/crates/skill-headless/Cargo.toml b/crates/skill-headless/Cargo.toml index d7d82cb1..48f37281 100644 --- a/crates/skill-headless/Cargo.toml +++ b/crates/skill-headless/Cargo.toml @@ -8,10 +8,10 @@ description = "Headless browser engine — CDP-like API over wry/tao for navigat [dependencies] anyhow = { workspace = true } # Windowing — hidden window hosts the webview; provides event loop + proxy. -tao = { version = "0.35", default-features = false, features = ["rwh_06"] } +tao = { version = "0.34", default-features = false, features = ["rwh_06"] } # WebView — system webview with full JS, DOM, network stack. -wry = { version = "0.55" } +wry = { version = "0.54" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/skill-settings/src/keychain.rs b/crates/skill-settings/src/keychain.rs index 82f6865a..c6926edb 100644 --- a/crates/skill-settings/src/keychain.rs +++ b/crates/skill-settings/src/keychain.rs @@ -75,16 +75,108 @@ pub struct Secrets { pub neurosity_device_id: String, } -/// Load all secrets from the system keychain. +// ── Lazy per-secret accessors ───────────────────────────────────────────────── +// +// macOS prompts for keychain access whenever the calling binary's code +// signature doesn't match the ACL on a stored item. A fresh app build has +// a fresh signature, so eagerly reading every secret at startup produces +// one prompt per item per process, before the user has done anything. +// +// These accessors read individual entries on demand, so the prompt only +// appears when the user initiates an action that actually needs the secret +// (e.g. clicking "Connect Emotiv" or opening the device settings tab). +// +// Each accessor is a no-op in debug builds — see [`load_secrets`]. + +pub fn get_api_token() -> String { + if cfg!(debug_assertions) { + return String::new(); + } + get_secret(KEY_API_TOKEN) +} + +pub fn set_api_token(value: &str) { + if cfg!(debug_assertions) { + return; + } + set_secret(KEY_API_TOKEN, value); +} + +pub fn get_emotiv_credentials() -> (String, String) { + if cfg!(debug_assertions) { + return (String::new(), String::new()); + } + (get_secret(KEY_EMOTIV_CLIENT_ID), get_secret(KEY_EMOTIV_CLIENT_SECRET)) +} + +pub fn get_idun_api_token() -> String { + if cfg!(debug_assertions) { + return String::new(); + } + get_secret(KEY_IDUN_API_TOKEN) +} + +pub fn get_oura_access_token() -> String { + if cfg!(debug_assertions) { + return String::new(); + } + get_secret(KEY_OURA_ACCESS_TOKEN) +} + +pub fn get_neurosity_credentials() -> (String, String, String) { + if cfg!(debug_assertions) { + return (String::new(), String::new(), String::new()); + } + ( + get_secret(KEY_NEUROSITY_EMAIL), + get_secret(KEY_NEUROSITY_PASSWORD), + get_secret(KEY_NEUROSITY_DEVICE_ID), + ) +} + +pub fn get_neurosity_device_id() -> String { + if cfg!(debug_assertions) { + return String::new(); + } + get_secret(KEY_NEUROSITY_DEVICE_ID) +} + +/// Write device-API secrets supplied in `secrets` to the keychain. +/// +/// Empty fields are **ignored** rather than treated as deletion: if the user +/// denies a keychain prompt during the GET round-trip, the in-memory copy of +/// untouched secrets will be empty, and we don't want to clobber valid stored +/// values on the next save. Use [`set_api_token`] (or extend with explicit +/// delete helpers) when an empty value is genuinely meant to clear. /// -/// In debug builds the keychain is **skipped** entirely to avoid macOS -/// Keychain authorization dialogs on every `cargo run` / `tauri dev` -/// (the dev binary has a different code signature each build, so macOS -/// asks for permission every time). Secrets fall back to the JSON -/// settings file which still contains them in dev mode. +/// Used by the daemon's `set_device_api_config` route. +pub fn save_device_api_secrets(secrets: &Secrets) { + if cfg!(debug_assertions) { + return; + } + let pairs: &[(&str, &str)] = &[ + (KEY_EMOTIV_CLIENT_ID, &secrets.emotiv_client_id), + (KEY_EMOTIV_CLIENT_SECRET, &secrets.emotiv_client_secret), + (KEY_IDUN_API_TOKEN, &secrets.idun_api_token), + (KEY_OURA_ACCESS_TOKEN, &secrets.oura_access_token), + (KEY_NEUROSITY_EMAIL, &secrets.neurosity_email), + (KEY_NEUROSITY_PASSWORD, &secrets.neurosity_password), + (KEY_NEUROSITY_DEVICE_ID, &secrets.neurosity_device_id), + ]; + for &(key, value) in pairs { + if !value.is_empty() { + set_secret(key, value); + } + } +} + +/// Load all secrets eagerly from the keychain. +/// +/// Retained only for the legacy round-trip through [`save_secrets`] used by +/// the Tauri shell's `save_settings_now`. New code should use the per-secret +/// accessors above so prompts only fire on user-initiated actions. pub fn load_secrets() -> Secrets { if cfg!(debug_assertions) { - eprintln!("[keychain] skipping keychain in debug build"); return Secrets::default(); } Secrets { @@ -101,19 +193,32 @@ pub fn load_secrets() -> Secrets { /// Save all secrets to the system keychain. /// +/// Empty values are **ignored** rather than treated as a deletion request. +/// This avoids clobbering previously-stored secrets when the caller's +/// in-memory copy was never populated (e.g. lazy-load callers that don't +/// hydrate every field). Use the dedicated `set_*` helpers above to +/// explicitly delete a secret. +/// /// No-op in debug builds (see [`load_secrets`] for rationale). pub fn save_secrets(secrets: &Secrets) { if cfg!(debug_assertions) { return; } - set_secret(KEY_API_TOKEN, &secrets.api_token); - set_secret(KEY_EMOTIV_CLIENT_ID, &secrets.emotiv_client_id); - set_secret(KEY_EMOTIV_CLIENT_SECRET, &secrets.emotiv_client_secret); - set_secret(KEY_IDUN_API_TOKEN, &secrets.idun_api_token); - set_secret(KEY_OURA_ACCESS_TOKEN, &secrets.oura_access_token); - set_secret(KEY_NEUROSITY_EMAIL, &secrets.neurosity_email); - set_secret(KEY_NEUROSITY_PASSWORD, &secrets.neurosity_password); - set_secret(KEY_NEUROSITY_DEVICE_ID, &secrets.neurosity_device_id); + let pairs: &[(&str, &str)] = &[ + (KEY_API_TOKEN, &secrets.api_token), + (KEY_EMOTIV_CLIENT_ID, &secrets.emotiv_client_id), + (KEY_EMOTIV_CLIENT_SECRET, &secrets.emotiv_client_secret), + (KEY_IDUN_API_TOKEN, &secrets.idun_api_token), + (KEY_OURA_ACCESS_TOKEN, &secrets.oura_access_token), + (KEY_NEUROSITY_EMAIL, &secrets.neurosity_email), + (KEY_NEUROSITY_PASSWORD, &secrets.neurosity_password), + (KEY_NEUROSITY_DEVICE_ID, &secrets.neurosity_device_id), + ]; + for &(key, value) in pairs { + if !value.is_empty() { + set_secret(key, value); + } + } } /// Migrate plaintext secrets from settings JSON into the keychain. diff --git a/crates/skill-settings/src/lib.rs b/crates/skill-settings/src/lib.rs index c5533bb8..7fde7a36 100644 --- a/crates/skill-settings/src/lib.rs +++ b/crates/skill-settings/src/lib.rs @@ -1313,20 +1313,16 @@ pub fn load_settings(skill_dir: &Path) -> UserSettings { } } - // ── Load secrets from keychain (release) or keep JSON values (debug) ── - if !cfg!(debug_assertions) { - let secrets = keychain::load_secrets(); - s.api_token = secrets.api_token; - s.device_api.emotiv_client_id = secrets.emotiv_client_id; - s.device_api.emotiv_client_secret = secrets.emotiv_client_secret; - s.device_api.idun_api_token = secrets.idun_api_token; - s.device_api.oura_access_token = secrets.oura_access_token; - s.device_api.neurosity_email = secrets.neurosity_email; - s.device_api.neurosity_password = secrets.neurosity_password; - s.device_api.neurosity_device_id = secrets.neurosity_device_id; - } - // In debug mode, secrets stay as loaded from the JSON file — no keychain - // interaction, no macOS authorization prompts on every dev build. + // Secrets are deliberately **not** hydrated here. Loading every secret at + // startup triggers one macOS keychain prompt per item per process whenever + // the binary's code signature changes (i.e. on every release upgrade), and + // `load_settings` is called by both the Tauri shell and the daemon during + // boot. Callers that actually need a secret read it on demand from + // `keychain::get_*`, so a prompt only appears when the user initiates an + // action that requires that specific secret. + // + // In debug builds, secrets stay as loaded from the JSON file (the JSON + // round-trip is preserved by `skip_secret_in_release` returning false). s } diff --git a/scripts/create-windows-nsis.ps1 b/scripts/create-windows-nsis.ps1 index 84de391c..46b0eaa6 100644 --- a/scripts/create-windows-nsis.ps1 +++ b/scripts/create-windows-nsis.ps1 @@ -56,6 +56,21 @@ $Conf = Get-Content (Join-Path $TauriDir "tauri.conf.json") -Raw | ConvertFrom-J $ProductName = $Conf.productName $ProductDisplayName = if ($ProductName.EndsWith("™")) { $ProductName } else { "$ProductName™" } $Version = $Conf.version + +# NSIS's VIProductVersion requires strict 4-segment numeric format X.X.X.X +# (Win32 VS_FIXEDFILEINFO). User-facing ProductVersion/FileVersion strings +# accept any text, but VIProductVersion does not — it rejects "-rc.N" suffixes. +# Map the SemVer string to a numeric 4-tuple: +# "0.0.130" -> "0.0.130.0" +# "0.0.130-rc.2" -> "0.0.130.2" (use RC number as fourth segment) +# "0.0.130-beta.7" -> "0.0.130.7" +if ($Version -match '^(\d+\.\d+\.\d+)(?:-[A-Za-z]+\.(\d+))?') { + $vibase = $Matches[1] + $vibuild = if ($Matches[2]) { $Matches[2] } else { "0" } + $VIVersion = "$vibase.$vibuild" +} else { + $VIVersion = "0.0.0.0" +} $Identifier = $Conf.identifier $BinaryName = "skill.exe" $TargetReleaseDir = Join-Path $TauriDir "target/$Target/release" @@ -531,7 +546,7 @@ $imageDirectives !insertmacro MUI_LANGUAGE "English" ; ── Version info ──────────────────────────────────────────────────────── -VIProductVersion "$Version.0" +VIProductVersion "$VIVersion" VIAddVersionKey "ProductName" "$ProductDisplayName" VIAddVersionKey "ProductVersion" "$Version" VIAddVersionKey "FileVersion" "$Version" diff --git a/scripts/release.js b/scripts/release.js index 00136269..c9a92b34 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -19,9 +19,27 @@ // works if no rebuild happens at promotion time. import { execSync, spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { baseVersion, bumpVersion } from "./version-utils.mjs"; +// GitHub caps PR/issue bodies at 65_536 chars. Leave headroom for the +// surrounding template; truncate the embedded notes if they exceed this. +const NOTES_MAX_CHARS = 50_000; + +function readReleaseNotes(version) { + const path = `changes/releases/${version}.md`; + if (!existsSync(path)) return null; + let body = readFileSync(path, "utf8").trim(); + // The file leads with `## [] — ` which is redundant with the + // PR's surrounding heading; strip it so the embedded section starts at the + // first content heading (Features / Bugfixes / etc.). + body = body.replace(/^##\s+\[[^\]]+\][^\n]*\n+/, ""); + if (body.length > NOTES_MAX_CHARS) { + body = `${body.slice(0, NOTES_MAX_CHARS)}\n\n_…notes truncated — see \`changes/releases/${version}.md\` for the full text._`; + } + return body; +} + // ── Shell + git helpers ───────────────────────────────────────────────────── function sh(cmd, args, opts = {}) { @@ -231,9 +249,11 @@ async function main() { prs = JSON.parse(prList.stdout || "[]"); } catch {} + const notes = readReleaseNotes(newVersion); + if (prs.length === 0) { log("gh pr create"); - const body = [ + const sections = [ `## Release v${base}`, "", `Tracking release candidates for **v${base}**.`, @@ -251,7 +271,11 @@ async function main() { `- ${tag}`, "", "_(more added as RCs are cut)_", - ].join("\n"); + ]; + if (notes) { + sections.push("", "---", "", `## What's in this release (\`${tag}\`)`, "", notes); + } + const body = sections.join("\n"); sh( "gh", [ @@ -273,11 +297,15 @@ async function main() { } else { const pr = prs[0]; log(`gh pr comment ${pr.number}`); - const body = [ + const sections = [ `🚀 New RC: \`${tag}\``, "", "CI is building. Once the workflow finishes, RC channel users will receive this build automatically on their next update check.", - ].join("\n"); + ]; + if (notes) { + sections.push("", "
Release notes for this RC", "", notes, "", "
"); + } + const body = sections.join("\n"); sh("gh", ["pr", "comment", String(pr.number), "--body", body], { check: true }); } diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 9474c361..c46fff93 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1,40 +1,135 @@ #!/usr/bin/env bash # smoke-test.sh — Launch the Skill app and run test.ts once it's ready. # +# Two modes, auto-selected: +# • headless (default in CI / non-TTY): app runs in the background, logs to +# a file; test.ts runs in the foreground with a bounded discovery timeout; +# the app is terminated on exit and the test's exit status propagates. +# • tmux (default in interactive shells): app + test.ts run in a split-pane +# tmux session you can attach to. Same behaviour as before. +# +# Override the mode with SMOKE_MODE=headless|tmux. Override the headless +# discovery + run timeout with SMOKE_TIMEOUT_SECS (default 180). +# # Usage: -# ./smoke-test.sh # auto-discover port via mDNS (retries until Ctrl-C) +# ./smoke-test.sh # auto-discover port # ./smoke-test.sh 62853 # pass explicit port to test.ts # ./smoke-test.sh --http # forward flags to test.ts # ./smoke-test.sh 62853 --ws # combine port + flags # -# Requires: tmux, Node ≥ 18 +# Requires: Node ≥ 18 (tmux only used in interactive mode). set -euo pipefail -SESSION="smoke" DIR="$(cd "$(dirname "$0")/.." && pwd)" -TEST_ARGS="${*:-}" # forward all args to test.ts - -# Kill previous session if it exists -tmux kill-session -t "$SESSION" 2>/dev/null || true - -tmux new-session -d -s "$SESSION" -c "$DIR" \ - "echo '═══ Starting Skill app ═══'; npm run tauri dev; echo '═══ App exited ═══'; read" \; \ - split-window -h -c "$DIR" "\ - echo '═══ Waiting for Skill to start… ═══' - sleep 5 - npx tsx test.ts $TEST_ARGS - STATUS=\$? - echo '' - if [ \$STATUS -eq 0 ]; then - echo '══════════════════════════' - echo ' ✓ SMOKE TEST PASSED' - echo '══════════════════════════' - else - echo '══════════════════════════' - echo ' ✗ SMOKE TEST FAILED' - echo '══════════════════════════' +TIMEOUT_SECS="${SMOKE_TIMEOUT_SECS:-180}" + +# ── Mode selection ──────────────────────────────────────────────────────────── +# +# Pick headless when stdout isn't a TTY (CI, log capture), or when CI=true, +# or when tmux is unavailable. Otherwise use the tmux split-pane. +choose_mode() { + if [ -n "${SMOKE_MODE:-}" ]; then + echo "$SMOKE_MODE" + return + fi + if [ "${CI:-}" = "true" ] || [ ! -t 1 ] || ! command -v tmux >/dev/null 2>&1; then + echo "headless" + else + echo "tmux" + fi +} +MODE="$(choose_mode)" + +# ── Headless mode ───────────────────────────────────────────────────────────── +run_headless() { + cd "$DIR" + local app_log + app_log="$(mktemp -t skill-smoke-app.XXXXXX.log)" + + echo "→ smoke (headless) — log: $app_log timeout: ${TIMEOUT_SECS}s" + + # Enable job control so the background `npm run tauri dev` becomes its own + # process group leader (PGID = PID). Without this, the npm → tauri → cargo → + # app chain inherits the script's PGID and a single SIGTERM only hits npm, + # leaving cargo + the app holding the listening port. + set -m + npm run tauri dev >"$app_log" 2>&1 & + local app_pid=$! + set +m + echo "→ app pid: $app_pid (process group leader)" + + cleanup() { + if kill -0 "$app_pid" 2>/dev/null; then + echo "→ stopping app (PID $app_pid)" + # Kill the whole process group: `npm run tauri dev` spawns a chain + # (npm → tauri → cargo → app), and SIGTERM on the parent alone leaves + # the cargo+app children orphaned to occupy the port. + kill -TERM -- "-$app_pid" 2>/dev/null || kill -TERM "$app_pid" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$app_pid" 2>/dev/null || break + sleep 1 + done + kill -KILL -- "-$app_pid" 2>/dev/null || kill -KILL "$app_pid" 2>/dev/null || true fi - echo 'Press Enter to close.'; read - exit \$STATUS" \; \ - attach + } + trap cleanup EXIT INT TERM + + # Hand the discovery timeout to test.ts so its retry loop exits cleanly + # if the app fails to register on mDNS. Reserve ~10s for the test run + # itself to start, but never less than 30s. + local discover_secs=$(( TIMEOUT_SECS - 10 )) + if [ "$discover_secs" -lt 30 ]; then discover_secs=30; fi + + local status=0 + SKILL_DISCOVER_TIMEOUT_SECS="$discover_secs" \ + npx tsx test.ts "$@" || status=$? + + echo + if [ "$status" -eq 0 ]; then + echo "══════════════════════════" + echo " ✓ SMOKE TEST PASSED" + echo "══════════════════════════" + else + echo "══════════════════════════" + echo " ✗ SMOKE TEST FAILED (exit $status)" + echo "──── App log (last 100 lines) ────" + tail -n 100 "$app_log" || true + echo "══════════════════════════" + fi + exit "$status" +} + +# ── Interactive tmux mode ───────────────────────────────────────────────────── +run_tmux() { + local session="smoke" + local test_args + test_args="$*" + tmux kill-session -t "$session" 2>/dev/null || true + tmux new-session -d -s "$session" -c "$DIR" \ + "echo '═══ Starting Skill app ═══'; npm run tauri dev; echo '═══ App exited ═══'; read" \; \ + split-window -h -c "$DIR" "\ + echo '═══ Waiting for Skill to start… ═══' + sleep 5 + npx tsx test.ts $test_args + STATUS=\$? + echo '' + if [ \$STATUS -eq 0 ]; then + echo '══════════════════════════' + echo ' ✓ SMOKE TEST PASSED' + echo '══════════════════════════' + else + echo '══════════════════════════' + echo ' ✗ SMOKE TEST FAILED' + echo '══════════════════════════' + fi + echo 'Press Enter to close.'; read + exit \$STATUS" \; \ + attach +} + +case "$MODE" in + headless) run_headless "$@" ;; + tmux) run_tmux "$@" ;; + *) echo "unknown SMOKE_MODE: $MODE (expected: headless | tmux)" >&2; exit 2 ;; +esac diff --git a/scripts/test-all.sh b/scripts/test-all.sh index cfff6ecd..a8aa6d10 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -154,11 +154,10 @@ for suite in "${SUITES[@]}"; do run_suite "Windows manifest" node scripts/check-windows-manifest.mjs || { $STOP_ON_FAIL && break; } ;; smoke) - if command -v tmux >/dev/null 2>&1 && [ -t 0 ]; then - run_suite "smoke test" bash scripts/smoke-test.sh || { $STOP_ON_FAIL && break; } - else - skip_suite "smoke test" "requires tmux + interactive terminal" - fi + # smoke-test.sh auto-selects headless mode when stdout isn't a TTY or + # CI=true, so it runs unattended in CI / piped shells. Interactive + # terminals get the tmux split-pane unless overridden by SMOKE_MODE. + run_suite "smoke test" bash scripts/smoke-test.sh || { $STOP_ON_FAIL && break; } ;; daemon) if ls src-tauri/target/*/release/bundle/dmg/*.dmg >/dev/null 2>&1 || \ diff --git a/src-tauri/src/helpers.rs b/src-tauri/src/helpers.rs index 564f9015..9fdf8d1c 100644 --- a/src-tauri/src/helpers.rs +++ b/src-tauri/src/helpers.rs @@ -11,7 +11,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_notification::NotificationExt; -use crate::settings::{save_secrets_from_settings, settings_path}; +use crate::settings::settings_path; use crate::state::*; use crate::ws_server::WsBroadcaster; use crate::MutexExt; @@ -269,13 +269,14 @@ pub(crate) fn save_settings_now(app: &AppHandle) { // Infrastructure / server config data.ws_host = s.ws_host.clone(); data.ws_port = s.ws_port; - data.api_token = s.api_token.clone(); + // Secrets (api_token, device_api credentials) are owned by the daemon's + // route handlers and stored exclusively in the system keychain — Tauri + // no longer round-trips them through AppState. See keychain::get_*. data.hf_endpoint = s.hf_endpoint.clone(); data.update_check_interval_secs = s.update_check_interval_secs; // Hardware / device config data.openbci = s.openbci_config.clone(); - data.device_api = s.device_api_config.clone(); data.neutts = s.neutts_config.clone(); data.tts_preload = s.tts_preload; data.screenshot = s.screenshot_config.clone(); @@ -304,9 +305,6 @@ pub(crate) fn save_settings_now(app: &AppHandle) { drop(s); - // Persist secrets to the system keychain (encrypted, survives updates). - save_secrets_from_settings(&data); - if let Ok(json) = serde_json::to_string_pretty(&data) { if let Err(e) = std::fs::write(&path, &json) { eprintln!("[settings] save error: {e}"); diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs index b41550a7..09d88baa 100644 --- a/src-tauri/src/setup.rs +++ b/src-tauri/src/setup.rs @@ -391,11 +391,10 @@ fn load_and_apply_settings(app: &mut tauri::App, skill_dir: &std::path::Path) { s.hooks = data.hooks; s.ws_host = data.ws_host.clone(); s.ws_port = data.ws_port; - s.api_token = data.api_token.clone(); + // Secrets stay in the keychain; Tauri no longer caches them in AppState. s.hf_endpoint = data.hf_endpoint.clone(); s.update_check_interval_secs = data.update_check_interval_secs; s.openbci_config = data.openbci; - s.device_api_config = data.device_api; s.scanner_config = data.scanner; s.location_enabled = data.location_enabled; s.inference_device = data.inference_device.clone(); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 24d001a3..9d61eaed 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -376,7 +376,6 @@ pub struct AppState { // ── Network / services ──────────────────────────────────────────────── pub ws_host: String, pub ws_port: u16, - pub api_token: String, pub hf_endpoint: String, pub update_check_interval_secs: u64, /// Set by the frontend when an update has been downloaded and is ready @@ -385,7 +384,6 @@ pub struct AppState { // ── Device configs ──────────────────────────────────────────────────── pub openbci_config: crate::settings::OpenBciConfig, - pub device_api_config: crate::settings::DeviceApiConfig, pub scanner_config: crate::settings::ScannerConfig, /// Location services enabled by the user (default false). @@ -497,7 +495,6 @@ impl Default for AppState { )), ws_host: default_ws_host(), ws_port: default_ws_port(), - api_token: String::new(), hf_endpoint: skill_settings::default_hf_endpoint(), update_check_interval_secs: default_update_check_interval(), update_ready_to_install: false, @@ -506,7 +503,6 @@ impl Default for AppState { inference_device: skill_settings::default_inference_device(), llm_gpu_layers_saved: skill_settings::default_llm_gpu_layers_saved(), exg_inference_device: skill_settings::default_exg_inference_device(), - device_api_config: crate::settings::DeviceApiConfig::default(), scanner_config: crate::settings::ScannerConfig::default(), neutts_config: NeuttsConfig::default(), tts_preload: true, diff --git a/src-tauri/src/window_cmds.rs b/src-tauri/src/window_cmds.rs index f77ed190..bad14420 100644 --- a/src-tauri/src/window_cmds.rs +++ b/src-tauri/src/window_cmds.rs @@ -46,6 +46,24 @@ impl<'a> Default for WindowSpec<'a> { } } +/// Clamp a requested logical inner size so it fits on the primary monitor. +/// +/// Without this, windows configured larger than the user's screen (e.g. the +/// 880-tall onboarding window on a 13" laptop) open partially off-screen with +/// their footer controls unreachable. +fn clamp_to_monitor(app: &AppHandle, requested: (f64, f64)) -> (f64, f64) { + // Reserve room for menubar/taskbar/dock so the title bar stays visible. + const CHROME_MARGIN: f64 = 80.0; + const FLOOR: f64 = 320.0; + let Ok(Some(monitor)) = app.primary_monitor() else { + return requested; + }; + let scale = monitor.scale_factor(); + let max_w = (monitor.size().width as f64 / scale - CHROME_MARGIN).max(FLOOR); + let max_h = (monitor.size().height as f64 / scale - CHROME_MARGIN).max(FLOOR); + (requested.0.min(max_w), requested.1.min(max_h)) +} + /// Focus an existing window or create a new one from `spec`. /// /// Deduplicates the repeated "check-existing → unminimize/show/focus → or build new" @@ -57,20 +75,23 @@ pub(crate) fn focus_or_create(app: &AppHandle, spec: WindowSpec) -> Result<(), S let _ = win.set_focus(); return Ok(()); } + let (inner_w, inner_h) = clamp_to_monitor(app, spec.inner_size); let mut builder = tauri::WebviewWindowBuilder::new( app, spec.label, tauri::WebviewUrl::App(spec.route.into()), ) .title(spec.title) - .inner_size(spec.inner_size.0, spec.inner_size.1) + .inner_size(inner_w, inner_h) .resizable(spec.resizable) .center() .decorations(false) .transparent(true); if let Some((w, h)) = spec.min_inner_size { - builder = builder.min_inner_size(w, h); + // Min must not exceed the (possibly clamped) inner size, or the + // builder will silently grow the window past the screen. + builder = builder.min_inner_size(w.min(inner_w), h.min(inner_h)); } if spec.always_on_top { builder = builder.always_on_top(true); @@ -102,21 +123,21 @@ pub(crate) fn focus_or_create_with_emit( let _ = win.emit(event, payload.to_string()); return Ok(()); } - // Fall through to normal builder + let (inner_w, inner_h) = clamp_to_monitor(app, spec.inner_size); let mut builder = tauri::WebviewWindowBuilder::new( app, spec.label, tauri::WebviewUrl::App(spec.route.into()), ) .title(spec.title) - .inner_size(spec.inner_size.0, spec.inner_size.1) + .inner_size(inner_w, inner_h) .resizable(spec.resizable) .center() .decorations(false) .transparent(true); if let Some((w, h)) = spec.min_inner_size { - builder = builder.min_inner_size(w, h); + builder = builder.min_inner_size(w.min(inner_w), h.min(inner_h)); } builder diff --git a/test.ts b/test.ts index 00370299..9a5a3479 100644 --- a/test.ts +++ b/test.ts @@ -312,13 +312,24 @@ function fmt(v: unknown): string { async function discover(): Promise { if (PORT) return PORT; - // Retry discovery indefinitely until Ctrl-C. - // Each attempt tries mDNS (5s timeout) then lsof fallback. + // Bounded discovery for CI/headless callers. Set SKILL_DISCOVER_TIMEOUT_SECS + // to cap total wall time spent retrying mDNS + lsof. Unset = retry forever + // (the historical interactive behaviour — Ctrl-C to abort). + const timeoutEnv = process.env.SKILL_DISCOVER_TIMEOUT_SECS; + const deadlineMs = timeoutEnv ? Date.now() + Number(timeoutEnv) * 1000 : Number.POSITIVE_INFINITY; + let attempt = 0; while (true) { + if (Date.now() > deadlineMs) { + throw new Error( + `discovery timed out after ${timeoutEnv}s — app did not register on mDNS or open a listening port`, + ); + } attempt++; if (attempt === 1) { - info("discovering Skill port (retries until Ctrl-C)…"); + info(timeoutEnv + ? `discovering Skill port (timeout ${timeoutEnv}s)…` + : "discovering Skill port (retries until Ctrl-C)…"); } else { info(`discovery attempt #${attempt} — retrying in 3s…`); await new Promise(r => setTimeout(r, 3000)); From 0f0c0029e03c1e25c6be6d96b10afb5e2c43aafd Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Wed, 29 Apr 2026 16:08:55 -0400 Subject: [PATCH 06/98] 0.0.130-rc.3 --- CHANGELOG.md | 7 ++ Cargo.lock | 6 +- changes/releases/0.0.130-rc.3.md | 6 ++ crates/skill-settings/src/keychain.rs | 116 ++++++++++++++++---------- deny.toml | 2 - package.json | 2 +- scripts/release.js | 79 +++++++++++++++++- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 9 files changed, 170 insertions(+), 52 deletions(-) create mode 100644 changes/releases/0.0.130-rc.3.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 87a7a4e0..87de3c36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5402,3 +5402,10 @@ The heatmap merges EEG data points with the closest timeline events to show whic ### Features - fix win/linux + +## [0.0.130-rc.3] — 2026-04-29 + +### Features + +- fixed issues with the metrics, keychain access (3 -> 1 + on demand), windows CI +- umap e2e diff --git a/Cargo.lock b/Cargo.lock index 942526bf..f8b5cfe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7290,9 +7290,9 @@ dependencies = [ [[package]] name = "llmfit-core" -version = "0.9.16" +version = "0.9.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5eb09b5be6bade227582226bd0d74069abd88460756af4e93a8dfe97c38d57c" +checksum = "ab3d28d163be4423375ed1f7fb0bdc23e40fd1fe56e7a5beb025a9240bb6b978" dependencies = [ "dirs", "http 1.4.0", @@ -12130,7 +12130,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.2" +version = "0.0.130-rc.3" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/changes/releases/0.0.130-rc.3.md b/changes/releases/0.0.130-rc.3.md new file mode 100644 index 00000000..c2f9fb6b --- /dev/null +++ b/changes/releases/0.0.130-rc.3.md @@ -0,0 +1,6 @@ +## [0.0.130-rc.3] — 2026-04-29 + +### Features + +- fixed issues with the metrics, keychain access (3 -> 1 + on demand), windows CI +- umap e2e diff --git a/crates/skill-settings/src/keychain.rs b/crates/skill-settings/src/keychain.rs index c6926edb..dcfef5b8 100644 --- a/crates/skill-settings/src/keychain.rs +++ b/crates/skill-settings/src/keychain.rs @@ -10,11 +10,59 @@ //! Secrets survive app re-installs and build updates because they live in //! the system credential store, not in the app data directory. +#[cfg(not(debug_assertions))] use keyring::Entry; /// Service name used as the keychain namespace for all NeuroSkill secrets. +#[cfg(not(debug_assertions))] const SERVICE: &str = "com.neuroskill.skill"; +// ── Debug-build in-memory store ────────────────────────────────────────────── +// +// Debug builds (`cargo run`, `tauri dev`, `cargo test`) deliberately avoid the +// OS keychain — every rebuild produces a binary with a different code +// signature, which on macOS triggers a fresh authorization prompt. The dev +// loop becomes unbearable. +// +// Pre-this commit the workaround was to short-circuit getters to `""` and +// setters to no-op, but that broke any code (including unit tests) that +// expected `set` then `get` to roundtrip. We now keep a process-local +// `Mutex` instead — no OS prompt, but values survive within the same +// process so the route handlers behave like real keychain code. +// +// Release builds bypass this entirely and use `keyring::Entry`. + +#[cfg(debug_assertions)] +mod dev_store { + use std::collections::HashMap; + use std::sync::Mutex; + use std::sync::OnceLock; + + static STORE: OnceLock>> = OnceLock::new(); + + fn store() -> &'static Mutex> { + STORE.get_or_init(|| Mutex::new(HashMap::new())) + } + + pub fn get(key: &str) -> String { + store() + .lock() + .ok() + .and_then(|g| g.get(key).cloned()) + .unwrap_or_default() + } + + pub fn set(key: &str, value: &str) { + if let Ok(mut g) = store().lock() { + if value.is_empty() { + g.remove(key); + } else { + g.insert(key.to_string(), value.to_string()); + } + } + } +} + // ── Key names ───────────────────────────────────────────────────────────────── const KEY_API_TOKEN: &str = "api_token"; @@ -27,7 +75,13 @@ const KEY_NEUROSITY_PASSWORD: &str = "neurosity_password"; const KEY_NEUROSITY_DEVICE_ID: &str = "neurosity_device_id"; // ── Low-level helpers ───────────────────────────────────────────────────────── +// +// In debug builds these route through `dev_store` (process-local, no OS +// keychain access). In release they hit the real OS keychain. Per-secret +// helpers above don't need their own `cfg!(debug_assertions)` checks — the +// switch happens here so the callers behave identically in both modes. +#[cfg(not(debug_assertions))] fn get_secret(key: &str) -> String { match Entry::new(SERVICE, key).and_then(|e| e.get_password()) { Ok(v) => v, @@ -39,6 +93,12 @@ fn get_secret(key: &str) -> String { } } +#[cfg(debug_assertions)] +fn get_secret(key: &str) -> String { + dev_store::get(key) +} + +#[cfg(not(debug_assertions))] fn set_secret(key: &str, value: &str) { let entry = match Entry::new(SERVICE, key) { Ok(e) => e, @@ -53,13 +113,16 @@ fn set_secret(key: &str, value: &str) { Ok(()) | Err(keyring::Error::NoEntry) => {} Err(e) => eprintln!("[keychain] failed to delete {key}: {e}"), } - } else { - if let Err(e) = entry.set_password(value) { - eprintln!("[keychain] failed to store {key}: {e}"); - } + } else if let Err(e) = entry.set_password(value) { + eprintln!("[keychain] failed to store {key}: {e}"); } } +#[cfg(debug_assertions)] +fn set_secret(key: &str, value: &str) { + dev_store::set(key, value); +} + // ── Public API ──────────────────────────────────────────────────────────────── /// All secret fields managed by the keychain. @@ -82,51 +145,34 @@ pub struct Secrets { // a fresh signature, so eagerly reading every secret at startup produces // one prompt per item per process, before the user has done anything. // -// These accessors read individual entries on demand, so the prompt only -// appears when the user initiates an action that actually needs the secret -// (e.g. clicking "Connect Emotiv" or opening the device settings tab). -// -// Each accessor is a no-op in debug builds — see [`load_secrets`]. +// These accessors read individual entries on demand, so the OS keychain +// prompt only appears when the user initiates an action that actually needs +// the secret (e.g. clicking "Connect Emotiv" or opening the device settings +// tab). In debug builds the low-level helpers route through `dev_store` +// instead of the OS keychain, so dev/test workflows roundtrip values without +// any auth dialogs. pub fn get_api_token() -> String { - if cfg!(debug_assertions) { - return String::new(); - } get_secret(KEY_API_TOKEN) } pub fn set_api_token(value: &str) { - if cfg!(debug_assertions) { - return; - } set_secret(KEY_API_TOKEN, value); } pub fn get_emotiv_credentials() -> (String, String) { - if cfg!(debug_assertions) { - return (String::new(), String::new()); - } (get_secret(KEY_EMOTIV_CLIENT_ID), get_secret(KEY_EMOTIV_CLIENT_SECRET)) } pub fn get_idun_api_token() -> String { - if cfg!(debug_assertions) { - return String::new(); - } get_secret(KEY_IDUN_API_TOKEN) } pub fn get_oura_access_token() -> String { - if cfg!(debug_assertions) { - return String::new(); - } get_secret(KEY_OURA_ACCESS_TOKEN) } pub fn get_neurosity_credentials() -> (String, String, String) { - if cfg!(debug_assertions) { - return (String::new(), String::new(), String::new()); - } ( get_secret(KEY_NEUROSITY_EMAIL), get_secret(KEY_NEUROSITY_PASSWORD), @@ -135,9 +181,6 @@ pub fn get_neurosity_credentials() -> (String, String, String) { } pub fn get_neurosity_device_id() -> String { - if cfg!(debug_assertions) { - return String::new(); - } get_secret(KEY_NEUROSITY_DEVICE_ID) } @@ -151,9 +194,6 @@ pub fn get_neurosity_device_id() -> String { /// /// Used by the daemon's `set_device_api_config` route. pub fn save_device_api_secrets(secrets: &Secrets) { - if cfg!(debug_assertions) { - return; - } let pairs: &[(&str, &str)] = &[ (KEY_EMOTIV_CLIENT_ID, &secrets.emotiv_client_id), (KEY_EMOTIV_CLIENT_SECRET, &secrets.emotiv_client_secret), @@ -176,9 +216,6 @@ pub fn save_device_api_secrets(secrets: &Secrets) { /// the Tauri shell's `save_settings_now`. New code should use the per-secret /// accessors above so prompts only fire on user-initiated actions. pub fn load_secrets() -> Secrets { - if cfg!(debug_assertions) { - return Secrets::default(); - } Secrets { api_token: get_secret(KEY_API_TOKEN), emotiv_client_id: get_secret(KEY_EMOTIV_CLIENT_ID), @@ -199,11 +236,7 @@ pub fn load_secrets() -> Secrets { /// hydrate every field). Use the dedicated `set_*` helpers above to /// explicitly delete a secret. /// -/// No-op in debug builds (see [`load_secrets`] for rationale). pub fn save_secrets(secrets: &Secrets) { - if cfg!(debug_assertions) { - return; - } let pairs: &[(&str, &str)] = &[ (KEY_API_TOKEN, &secrets.api_token), (KEY_EMOTIV_CLIENT_ID, &secrets.emotiv_client_id), @@ -228,9 +261,6 @@ pub fn save_secrets(secrets: &Secrets) { /// into the keychain. Returns `true` if any migration happened (caller /// should re-save settings to strip the plaintext values). pub fn migrate_plaintext_secrets(secrets: &Secrets) -> bool { - if cfg!(debug_assertions) { - return false; - } let mut migrated = false; let pairs: &[(&str, &str)] = &[ diff --git a/deny.toml b/deny.toml index a7c85393..ffaa52af 100644 --- a/deny.toml +++ b/deny.toml @@ -203,8 +203,6 @@ skip = [ { crate = "bitflags@1.3.2" }, { crate = "winreg@0.55.0" }, { crate = "winreg@0.56.0" }, - { crate = "wry@0.54.4" }, - { crate = "wry@0.55.0" }, { crate = "zip@2.4.2" }, { crate = "zip@4.6.1" }, { crate = "zip@7.2.0" }, diff --git a/package.json b/package.json index 6bd0ac39..d126722b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.2", + "version": "0.0.130-rc.3", "description": "", "type": "module", "scripts": { diff --git a/scripts/release.js b/scripts/release.js index c9a92b34..bc647b4d 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -77,6 +77,75 @@ function gitTracksRemote(b) { return captureOut(`git for-each-ref --format=%(upstream:short) refs/heads/${b}`).length > 0; } +function gitTagExistsLocal(tag) { + return sh("git", ["rev-parse", "--verify", `refs/tags/${tag}`], { capture: true }).status === 0; +} + +function gitTagExistsOnAnyRemote(tag) { + const remotes = captureOut("git remote") + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + for (const remote of remotes) { + const r = sh("git", ["ls-remote", "--tags", "--exit-code", remote, `refs/tags/${tag}`], { capture: true }); + if (r.status === 0) return true; + } + return false; +} + +function gitHeadPackageVersion() { + // Read package.json at HEAD to confirm the current commit is the bump for `currentVersion`. + const out = sh("git", ["show", "HEAD:package.json"], { capture: true }); + if (out.status !== 0) return null; + try { + return JSON.parse(out.stdout).version || null; + } catch { + return null; + } +} + +/// Self-heal a half-finished previous iteration: if `currentVersion`'s tag +/// is missing locally or on the remote, push the branch (if needed) and +/// create + push the tag before we try to bump. Without this, every aborted +/// push (failed pre-push hook, killed CI, network blip) wedges the release +/// branch until someone runs `npm run tag` by hand. +function ensureCurrentVersionTagged({ currentVersion, branchName, onReleaseBranch }) { + if (!onReleaseBranch) return; // Cutting from main — there's no prior version on this branch to tag. + + const tag = `v${currentVersion}`; + const haveLocal = gitTagExistsLocal(tag); + const haveRemote = haveLocal && gitTagExistsOnAnyRemote(tag); + + if (haveLocal && haveRemote) return; // Nothing to recover. + + // Sanity: HEAD's package.json must match `currentVersion`. If it doesn't, + // we're not on the bump commit and tagging here would produce a wrong tag. + const headVersion = gitHeadPackageVersion(); + if (headVersion !== currentVersion) { + fail( + `Cannot self-heal: HEAD's package.json version (${headVersion ?? "unknown"}) doesn't match the ` + + `current version (${currentVersion}). Resolve manually: tag the right commit, push, then re-run.`, + ); + } + + log(`recovering: tag ${tag} is missing — completing the previous iteration first`); + + // The remote-tag check requires HEAD's commit to be reachable on the remote, + // so the branch must be pushed before we push the tag. + if (!gitTracksRemote(branchName)) { + log(`git push -u origin ${branchName} (recovery)`); + sh("git", ["push", "-u", "origin", branchName], { check: true }); + } else { + log("git push (recovery)"); + sh("git", ["push"], { check: true }); + } + + log("npm run tag (recovery)"); + sh("npm", ["run", "tag"], { check: true }); + + ok(`recovered: ${tag} tagged and pushed; resuming next-RC iteration`); +} + function ensureGhReady() { if (sh("gh", ["--version"], { capture: true }).status !== 0) { fail("`gh` (GitHub CLI) not installed. Install with `brew install gh` then `gh auth login`."); @@ -210,7 +279,15 @@ async function main() { sh("git", ["checkout", "-b", branchName], { check: true }); } - // ── 2. Run bump (mutates files, runs preflight, creates commit) ──────── + // ── 2. Self-heal: tag any prior iteration that didn't get pushed ─────── + // The previous run can die mid-flight (failed pre-push hook, killed CI, + // network blip) after the bump commit but before `npm run tag`. That + // leaves the release branch in a state where bump's preflight refuses to + // run because the current version isn't tagged. Detect + recover here so + // the user doesn't need to remember the manual `npm run tag` dance. + ensureCurrentVersionTagged({ currentVersion, branchName, onReleaseBranch }); + + // ── 3. Run bump (mutates files, runs preflight, creates commit) ──────── const bumpArgs = ["run", "bump", "--", "--rc"]; if (force) bumpArgs.push("--force"); log(`npm ${bumpArgs.join(" ")}`); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0fa29920..5292c529 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.2" +version = "0.0.130-rc.3" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e6141199..6ec92b92 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.2", + "version": "0.0.130-rc.3", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From 572902f228b201517a8b47f9dca5f6eb368fdf54 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Wed, 29 Apr 2026 16:46:48 -0400 Subject: [PATCH 07/98] 0.0.130-rc.4 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- changes/releases/0.0.130-rc.4.md | 5 +++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 changes/releases/0.0.130-rc.4.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 87de3c36..dacfc1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5409,3 +5409,9 @@ The heatmap merges EEG data points with the closest timeline events to show whic - fixed issues with the metrics, keychain access (3 -> 1 + on demand), windows CI - umap e2e + +## [0.0.130-rc.4] — 2026-04-29 + +### Features + +- Minor updates and improvements diff --git a/Cargo.lock b/Cargo.lock index f8b5cfe9..fda93c57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12130,7 +12130,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.3" +version = "0.0.130-rc.4" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/changes/releases/0.0.130-rc.4.md b/changes/releases/0.0.130-rc.4.md new file mode 100644 index 00000000..5e46fef9 --- /dev/null +++ b/changes/releases/0.0.130-rc.4.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.4] — 2026-04-29 + +### Features + +- Minor updates and improvements diff --git a/package.json b/package.json index d126722b..dcdfaa1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.3", + "version": "0.0.130-rc.4", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5292c529..428be0c1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.3" +version = "0.0.130-rc.4" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6ec92b92..6d0ece40 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.3", + "version": "0.0.130-rc.4", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From 7902bb41ec02848c9001ff79c0a5da82a7006ca8 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Wed, 29 Apr 2026 17:01:08 -0400 Subject: [PATCH 08/98] fix vulkan cache on windows ci --- .github/workflows/ci.yml | 4 ++++ .github/workflows/release-windows.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d02c7d62..7303180b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -610,6 +610,10 @@ jobs: # only happens once per change to the install script. On cache hit the # install script detects the SDK via filesystem and skips the download. - name: Cache Vulkan SDK + # Don't fail the job on transient cache-service errors (actions/cache@v5 + # occasionally returns "failure" instead of "miss"). The install script + # below handles a missing SDK by downloading it on the spot. + continue-on-error: true uses: actions/cache@v5 with: path: C:\VulkanSDK diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index ace22bb4..5a00b336 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -225,6 +225,10 @@ jobs: # toolchain. Installing them in a single step via background jobs cuts # ~1-2 min of sequential wait time on cache-miss runs. - name: Cache Vulkan SDK + # Don't fail the job on transient cache-service errors (actions/cache@v5 + # occasionally returns "failure" instead of "miss"). The install step + # below handles a missing SDK by downloading it on the spot. + continue-on-error: true uses: actions/cache@v5 with: path: C:\VulkanSDK From 34be9226b4cdcfad474965b4495e23cfce5e45a7 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Wed, 29 Apr 2026 21:14:57 -0400 Subject: [PATCH 09/98] 0.0.130-rc.5 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 6 +++--- changes/releases/0.0.130-rc.5.md | 5 +++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 changes/releases/0.0.130-rc.5.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dacfc1f8..314cc363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5415,3 +5415,9 @@ The heatmap merges EEG data points with the closest timeline events to show whic ### Features - Minor updates and improvements + +## [0.0.130-rc.5] — 2026-04-30 + +### Features + +- fix vulkan cache on windows ci diff --git a/Cargo.lock b/Cargo.lock index fda93c57..56cc5621 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8490,9 +8490,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.16.0" +version = "4.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e551a9f0db223eaf3eb156906f99f46897fd951ee66dd1cb0be14db4d36d2fa" +checksum = "3bdaf6120b9df005d37e58f6b75329be6255450453fbeba9ce4192324f921fb9" dependencies = [ "futures-lite", "log", @@ -12130,7 +12130,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.4" +version = "0.0.130-rc.5" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/changes/releases/0.0.130-rc.5.md b/changes/releases/0.0.130-rc.5.md new file mode 100644 index 00000000..b092198b --- /dev/null +++ b/changes/releases/0.0.130-rc.5.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.5] — 2026-04-30 + +### Features + +- fix vulkan cache on windows ci diff --git a/package.json b/package.json index dcdfaa1d..27d31762 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.4", + "version": "0.0.130-rc.5", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 428be0c1..d0eba84f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.4" +version = "0.0.130-rc.5" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6d0ece40..1210125b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.4", + "version": "0.0.130-rc.5", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From 75e53fad5c4d97daea51feca03b254f23847cda7 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Wed, 29 Apr 2026 21:36:20 -0400 Subject: [PATCH 10/98] 0.0.130-rc.6 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- changes/releases/0.0.130-rc.6.md | 5 +++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 changes/releases/0.0.130-rc.6.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 314cc363..2cbacbb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5421,3 +5421,9 @@ The heatmap merges EEG data points with the closest timeline events to show whic ### Features - fix vulkan cache on windows ci + +## [0.0.130-rc.6] — 2026-04-30 + +### Features + +- Minor updates and improvements diff --git a/Cargo.lock b/Cargo.lock index 56cc5621..475f18ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12130,7 +12130,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.5" +version = "0.0.130-rc.6" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/changes/releases/0.0.130-rc.6.md b/changes/releases/0.0.130-rc.6.md new file mode 100644 index 00000000..9769dc36 --- /dev/null +++ b/changes/releases/0.0.130-rc.6.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.6] — 2026-04-30 + +### Features + +- Minor updates and improvements diff --git a/package.json b/package.json index 27d31762..1d0fde53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.5", + "version": "0.0.130-rc.6", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d0eba84f..488ee310 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.5" +version = "0.0.130-rc.6" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1210125b..9f26e026 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.5", + "version": "0.0.130-rc.6", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From ff0119f6f2fdbbcc1d60ccfc2ed8d1bc834c182a Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 20:21:50 -0400 Subject: [PATCH 11/98] Replace per-app AppleScript window tracking (which triggered a macOS TCC Automation dialog for every new foreground app) with native Accessibility API (AXUIElement) + CoreGraphics calls that require only a single one-time "Accessibility" permission for NeuroSkill. --- Cargo.lock | 2 + crates/skill-daemon/Cargo.toml | 2 + crates/skill-daemon/src/activity.rs | 482 ++++++++++++++++++++++++---- 3 files changed, 431 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 475f18ef..8199a356 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12271,6 +12271,8 @@ dependencies = [ "neurosky", "notify", "notify-rust", + "objc2 0.6.4", + "objc2-app-kit", "osf-rs", "rand 0.10.1", "regex", diff --git a/crates/skill-daemon/Cargo.toml b/crates/skill-daemon/Cargo.toml index 0ff3e4b9..68862f59 100644 --- a/crates/skill-daemon/Cargo.toml +++ b/crates/skill-daemon/Cargo.toml @@ -133,6 +133,8 @@ tokio-tungstenite = "0.28" # macOS: use Apple Accelerate for BLAS (AMX coprocessor on M-series) [target.'cfg(target_os = "macos")'.dependencies] burn-ndarray = { version = "0.20.1", default-features = false, features = ["blas-accelerate"], optional = true } +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplication"] } # Linux: use system OpenBLAS when available [target.'cfg(target_os = "linux")'.dependencies] diff --git a/crates/skill-daemon/src/activity.rs b/crates/skill-daemon/src/activity.rs index bd51a87c..5baf5df1 100644 --- a/crates/skill-daemon/src/activity.rs +++ b/crates/skill-daemon/src/activity.rs @@ -118,8 +118,229 @@ fn run_osascript(script: &str) -> Option { Some(String::from_utf8_lossy(&out.stdout).to_string()) } +/// macOS: try the native Accessibility-API path first; fall back to +/// AppleScript only when Accessibility permission has not been granted yet. +/// +/// The native path (`ax_poll_active_window`) requires ONE one-time +/// "Accessibility" permission for NeuroSkill that covers every application +/// forever — no per-app Automation dialogs appear. The AppleScript fallback +/// (`applescript_poll_active_window`) may trigger macOS TCC dialogs for each +/// new app that comes to the foreground. #[cfg(target_os = "macos")] fn poll_active_window() -> Option { + ax_poll_active_window().or_else(applescript_poll_active_window) +} + +/// Native macOS window polling via NSWorkspace + Accessibility API. +/// +/// * App name / path — obtained from `NSWorkspace.frontmostApplication` +/// (no permissions required at all). +/// * Window title — obtained via `AXFocusedWindow` + `AXTitle` +/// (single one-time "Accessibility" permission for NeuroSkill). +/// * Document path — obtained via `AXDocument` on the focused window +/// (same Accessibility permission; replaces the per-app AppleScript lookup). +/// +/// Returns `None` (causing a fall-through to AppleScript) if Accessibility +/// permission is not yet granted. +#[cfg(target_os = "macos")] +fn ax_poll_active_window() -> Option { + use std::ffi::{c_void, CStr}; + use std::os::raw::c_char; + + type CFTypeRef = *const c_void; + type CFStringRef = *const c_void; + type CFAllocatorRef = *const c_void; + type AXUIElementRef = *const c_void; + type AXError = i32; + + const AX_SUCCESS: AXError = 0; + const KCF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; + + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrusted() -> bool; + fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef; + fn AXUIElementCopyAttributeValue( + element: AXUIElementRef, + attribute: CFStringRef, + value: *mut CFTypeRef, + ) -> AXError; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFStringCreateWithCString(alloc: CFAllocatorRef, c_str: *const c_char, encoding: u32) -> CFStringRef; + fn CFStringGetLength(s: CFStringRef) -> isize; + fn CFStringGetMaximumSizeForEncoding(len: isize, encoding: u32) -> isize; + fn CFStringGetCString(s: CFStringRef, buf: *mut c_char, size: isize, encoding: u32) -> bool; + fn CFRelease(cf: CFTypeRef); + } + + // SAFETY: AXIsProcessTrusted is thread-safe and returns immediately. + if !unsafe { AXIsProcessTrusted() } { + // Accessibility not yet granted — fall through to AppleScript path. + return None; + } + + // ── Step 1: frontmost app info from NSWorkspace (zero permissions) ──────── + let (pid, app_name, app_path) = { + use objc2::msg_send; + use objc2::runtime::AnyObject; + use objc2_app_kit::NSWorkspace; + + // SAFETY: NSWorkspace and NSRunningApplication are stable AppKit APIs. + // Returned Objective-C objects have autorelease lifetime tied to the + // current thread's autorelease pool which Tauri/the OS maintains. + unsafe { + let workspace = NSWorkspace::sharedWorkspace(); + let front_app: Option<&AnyObject> = msg_send![&workspace, frontmostApplication]; + let front_app = front_app?; + + let pid: i32 = msg_send![front_app, processIdentifier]; + if pid <= 0 { + return None; + } + + let name_obj: Option<&AnyObject> = msg_send![front_app, localizedName]; + let app_name = name_obj + .map(|n| { + let bytes: *const c_char = msg_send![n, UTF8String]; + if bytes.is_null() { + String::new() + } else { + CStr::from_ptr(bytes).to_string_lossy().into_owned() + } + }) + .unwrap_or_default(); + + let url_obj: Option<&AnyObject> = msg_send![front_app, executableURL]; + let app_path = url_obj + .and_then(|u| { + let path_obj: Option<&AnyObject> = msg_send![u, path]; + path_obj.map(|p| { + let bytes: *const c_char = msg_send![p, UTF8String]; + if bytes.is_null() { + String::new() + } else { + CStr::from_ptr(bytes).to_string_lossy().into_owned() + } + }) + }) + .unwrap_or_default(); + + (pid, app_name, app_path) + } + }; + + if app_name.is_empty() { + return None; + } + + // ── Step 2: window title + document path via AXUIElement ───────────────── + // One "Accessibility" permission covers all apps — no per-app dialogs. + let (window_title, document_path) = unsafe { + /// Convert a non-null CFStringRef to a Rust `String`. + /// + /// SAFETY: `s` must be a valid, non-null CFStringRef. + unsafe fn cfstr_to_string(s: CFStringRef, enc: u32) -> String { + unsafe { + let len = CFStringGetLength(s); + let max = CFStringGetMaximumSizeForEncoding(len, enc) + 1; + let mut buf: Vec = vec![0; max as usize]; + if CFStringGetCString(s, buf.as_mut_ptr(), max, enc) { + CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned() + } else { + String::new() + } + } + } + + let key_focused_win = CFStringCreateWithCString( + std::ptr::null(), + b"AXFocusedWindow\0".as_ptr() as *const c_char, + KCF_STRING_ENCODING_UTF8, + ); + let key_title = CFStringCreateWithCString( + std::ptr::null(), + b"AXTitle\0".as_ptr() as *const c_char, + KCF_STRING_ENCODING_UTF8, + ); + let key_document = CFStringCreateWithCString( + std::ptr::null(), + b"AXDocument\0".as_ptr() as *const c_char, + KCF_STRING_ENCODING_UTF8, + ); + + let app_ax = AXUIElementCreateApplication(pid); + + let mut win_ref: CFTypeRef = std::ptr::null(); + let err = AXUIElementCopyAttributeValue(app_ax, key_focused_win, &mut win_ref); + + let (title, doc_path) = if err == AX_SUCCESS && !win_ref.is_null() { + let mut title_ref: CFTypeRef = std::ptr::null(); + let title = if AXUIElementCopyAttributeValue(win_ref, key_title, &mut title_ref) == AX_SUCCESS + && !title_ref.is_null() + { + let t = cfstr_to_string(title_ref, KCF_STRING_ENCODING_UTF8); + CFRelease(title_ref); + t + } else { + String::new() + }; + + let mut doc_ref: CFTypeRef = std::ptr::null(); + let doc_path = if AXUIElementCopyAttributeValue(win_ref, key_document, &mut doc_ref) == AX_SUCCESS + && !doc_ref.is_null() + { + // AXDocument returns a URL string: "file:///path/to/doc.txt" + let raw = cfstr_to_string(doc_ref, KCF_STRING_ENCODING_UTF8); + CFRelease(doc_ref); + let path = raw.strip_prefix("file://").unwrap_or(&raw); + let decoded = urlencoding::decode(path) + .map(|s| s.into_owned()) + .unwrap_or_else(|_| path.to_string()); + if decoded.is_empty() { + None + } else { + Some(decoded) + } + } else { + None + }; + + CFRelease(win_ref); + (title, doc_path) + } else { + (String::new(), None) + }; + + // SAFETY: app_ax is a retained AXUIElementRef (CFType); must be released. + CFRelease(app_ax as CFTypeRef); + CFRelease(key_focused_win); + CFRelease(key_title); + CFRelease(key_document); + + (title, doc_path) + }; + + Some(ActiveWindowInfo { + app_name, + app_path, + window_title, + document_path, + activated_at: unix_secs(), + browser_title: None, // Enriched later in run_poller. + monitor_id: None, // Enriched later if multi-monitor detection succeeds. + }) +} + +/// AppleScript fallback for active-window polling (macOS). +/// +/// Used only when Accessibility permission has not been granted yet. +/// May trigger macOS TCC Automation permission dialogs for each new +/// foreground application. +#[cfg(target_os = "macos")] +fn applescript_poll_active_window() -> Option { let script = r#" tell application "System Events" set frontApp to first application process whose frontmost is true @@ -189,70 +410,221 @@ return appName & "|||" & appPath & "|||" & winTitle & "|||" & docPath"#; } /// Poll all visible windows on non-primary monitors (macOS only). -/// Returns a list of windows that are on secondary screens. +/// +/// Uses `CGWindowListCopyWindowInfo` (CoreGraphics) and `CGMainDisplayID` / +/// `CGDisplayPixelsWide` to detect secondary-monitor windows without any +/// AppleScript or TCC permission prompts. +/// +/// Window titles (`kCGWindowName`) may be empty without Screen Recording +/// permission; owner names (`kCGWindowOwnerName`) are always available. #[cfg(target_os = "macos")] fn poll_secondary_windows() -> Vec { - // Use AppleScript to get all visible windows with their positions, - // then compare against screen bounds to determine which monitor. - let script = r#" -set result to "" -tell application "System Events" - set frontName to name of first application process whose frontmost is true - repeat with proc in (application processes whose visible is true) - set procName to name of proc - if procName is not frontName then - try - repeat with w in windows of proc - try - set winTitle to name of w - set winPos to position of w - set xPos to item 1 of winPos - -- Use x position to infer monitor (primary is typically x >= 0 and < primary width) - set result to result & procName & "|||" & winTitle & "|||" & xPos & linefeed - end try - end repeat - end try - end if - end repeat -end tell -return result"#; - - let out = match run_osascript(script) { - Some(s) => s, - None => return vec![], - }; + use std::ffi::{c_void, CStr}; + use std::os::raw::c_char; + + type CFTypeRef = *const c_void; + type CFDictionaryRef = *const c_void; + type CFStringRef = *const c_void; + type CFAllocatorRef = *const c_void; + type CFArrayRef = *const c_void; + type CFIndex = isize; + type CFNumberType = i32; + type CGWindowID = u32; + + const ON_SCREEN_ONLY: u32 = 1 << 0; + const EXCLUDE_DESKTOP: u32 = 1 << 4; + const K_CG_NULL_WINDOW_ID: CGWindowID = 0; + const K_CF_NUMBER_SINT32_TYPE: CFNumberType = 3; + const K_CF_NUMBER_FLOAT64_TYPE: CFNumberType = 13; + const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; + + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGWindowListCopyWindowInfo(option: u32, relativeToWindow: CGWindowID) -> CFArrayRef; + fn CGMainDisplayID() -> u32; + fn CGDisplayPixelsWide(display: u32) -> usize; + } - // Parse: each line is "appName|||windowTitle|||xPosition" - // Query actual primary screen width to avoid hardcoded values. - let primary_width: i64 = run_osascript("tell application \"Finder\" to get bounds of window of desktop") - .and_then(|s| s.split(',').nth(2)?.trim().parse::().ok()) - .unwrap_or(2000); + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFArrayGetCount(theArray: CFArrayRef) -> CFIndex; + fn CFArrayGetValueAtIndex(theArray: CFArrayRef, idx: CFIndex) -> CFTypeRef; + fn CFDictionaryGetValue(dict: CFDictionaryRef, key: CFStringRef) -> CFTypeRef; + fn CFNumberGetValue(number: CFTypeRef, the_type: CFNumberType, value_ptr: *mut i64) -> bool; + fn CFRelease(cf: CFTypeRef); + fn CFStringCreateWithCString(alloc: CFAllocatorRef, c_str: *const c_char, encoding: u32) -> CFStringRef; + fn CFStringGetLength(s: CFStringRef) -> isize; + fn CFStringGetMaximumSizeForEncoding(len: isize, encoding: u32) -> isize; + fn CFStringGetCString(s: CFStringRef, buf: *mut c_char, size: isize, encoding: u32) -> bool; + } + + // SAFETY: CoreGraphics C APIs — all pointers are valid and non-null-checked. + unsafe { + /// Create a UTF-8 CFString from a NUL-terminated byte literal. + /// + /// SAFETY: `s` must be a NUL-terminated byte slice. Caller must CFRelease. + unsafe fn cfstr(s: &[u8]) -> CFStringRef { + unsafe { + CFStringCreateWithCString(std::ptr::null(), s.as_ptr() as *const c_char, K_CF_STRING_ENCODING_UTF8) + } + } - out.lines() - .filter_map(|line| { - let line = line.trim(); - if line.is_empty() { + /// Read a CFNumber as i32. + /// + /// SAFETY: `n` must be a valid CFNumber (or null). + unsafe fn cfnum_i32(n: CFTypeRef) -> Option { + if n.is_null() { return None; } - let mut parts = line.splitn(3, "|||"); - let app_name = parts.next()?.trim().to_string(); - let window_title = parts.next()?.trim().to_string(); - let x_pos: i64 = parts.next()?.trim().parse().ok()?; - if app_name.is_empty() || window_title.is_empty() { + let mut v: i64 = 0; + unsafe { + if CFNumberGetValue(n, K_CF_NUMBER_SINT32_TYPE, &mut v) { + Some(v as i32) + } else { + None + } + } + } + + /// Read a CFNumber as f64. + /// + /// SAFETY: `n` must be a valid CFNumber (or null). + unsafe fn cfnum_f64(n: CFTypeRef) -> Option { + if n.is_null() { return None; } - // If window is outside primary monitor bounds, it's on a secondary monitor. - if x_pos < 0 || x_pos >= primary_width { - Some(SecondaryWindowInfo { - app_name, - window_title, - monitor_id: if x_pos < 0 { 2 } else { 1 }, - }) - } else { - None + let mut v: i64 = 0; + unsafe { + // CFNumberGetValue writes the numeric bits; reinterpret as f64. + if CFNumberGetValue(n, K_CF_NUMBER_FLOAT64_TYPE, &mut v) { + Some(f64::from_bits(v as u64)) + } else { + None + } } - }) - .collect() + } + + /// Convert a non-null CFStringRef to a Rust String. + /// + /// SAFETY: `s` must be a valid, non-null CFStringRef. + unsafe fn cfstr_to_string(s: CFStringRef) -> String { + unsafe { + let len = CFStringGetLength(s); + let max = CFStringGetMaximumSizeForEncoding(len, K_CF_STRING_ENCODING_UTF8) + 1; + let mut buf: Vec = vec![0; max as usize]; + if CFStringGetCString(s, buf.as_mut_ptr(), max, K_CF_STRING_ENCODING_UTF8) { + CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned() + } else { + String::new() + } + } + } + + // Primary display width — used to determine which monitor a window is on. + let primary_width = CGDisplayPixelsWide(CGMainDisplayID()) as i64; + + let key_pid = cfstr(b"kCGWindowOwnerPID\0"); + let key_layer = cfstr(b"kCGWindowLayer\0"); + let key_owner_name = cfstr(b"kCGWindowOwnerName\0"); + let key_name = cfstr(b"kCGWindowName\0"); + let key_bounds = cfstr(b"kCGWindowBounds\0"); + let key_x = cfstr(b"X\0"); + + let list = CGWindowListCopyWindowInfo(ON_SCREEN_ONLY | EXCLUDE_DESKTOP, K_CG_NULL_WINDOW_ID); + if list.is_null() { + CFRelease(key_pid); + CFRelease(key_layer); + CFRelease(key_owner_name); + CFRelease(key_name); + CFRelease(key_bounds); + CFRelease(key_x); + return vec![]; + } + + // Identify the frontmost app's PID so we can skip its windows. + let frontmost_pid: i32 = { + use objc2::msg_send; + use objc2::runtime::AnyObject; + use objc2_app_kit::NSWorkspace; + let workspace = NSWorkspace::sharedWorkspace(); + let front_app: Option<&AnyObject> = msg_send![&workspace, frontmostApplication]; + front_app.map(|a| msg_send![a, processIdentifier]).unwrap_or(-1) + }; + + let count = CFArrayGetCount(list); + let mut results: Vec = Vec::new(); + + for i in 0..count { + let dict = CFArrayGetValueAtIndex(list, i); + if dict.is_null() { + continue; + } + + // Layer 0 = normal windows only. + let layer = cfnum_i32(CFDictionaryGetValue(dict, key_layer)).unwrap_or(-1); + if layer != 0 { + continue; + } + + // Skip the frontmost app's windows (those belong to primary tracking). + let pid = cfnum_i32(CFDictionaryGetValue(dict, key_pid)).unwrap_or(-1); + if pid == frontmost_pid { + continue; + } + + // Window x-position from bounds dictionary. + let bounds_dict = CFDictionaryGetValue(dict, key_bounds); + if bounds_dict.is_null() { + continue; + } + let x_val = CFDictionaryGetValue(bounds_dict, key_x); + let x_pos = cfnum_f64(x_val).unwrap_or(0.0) as i64; + + // Only include windows that are outside the primary monitor. + if x_pos >= 0 && x_pos < primary_width { + continue; + } + + let owner_name_ref = CFDictionaryGetValue(dict, key_owner_name); + if owner_name_ref.is_null() { + continue; + } + let app_name = cfstr_to_string(owner_name_ref); + if app_name.is_empty() { + continue; + } + + // kCGWindowName may be null without Screen Recording permission; + // fall back to the app name to keep the record useful. + let win_name_ref = CFDictionaryGetValue(dict, key_name); + let window_title = if win_name_ref.is_null() { + app_name.clone() + } else { + let t = cfstr_to_string(win_name_ref); + if t.is_empty() { + app_name.clone() + } else { + t + } + }; + + results.push(SecondaryWindowInfo { + app_name, + window_title, + monitor_id: if x_pos < 0 { 2 } else { 1 }, + }); + } + + CFRelease(list); + CFRelease(key_pid); + CFRelease(key_layer); + CFRelease(key_owner_name); + CFRelease(key_name); + CFRelease(key_bounds); + CFRelease(key_x); + + results + } } /// Poll visible windows on non-primary monitors (Linux). From 4b652e4a21cf02762452feec9772d93f657c94b0 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 20:23:28 -0400 Subject: [PATCH 12/98] 0.0.130-rc.7 --- CHANGELOG.md | 6 + Cargo.lock | 569 ++++++++++++------------------- changes/releases/0.0.130-rc.7.md | 5 + package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 235 insertions(+), 351 deletions(-) create mode 100644 changes/releases/0.0.130-rc.7.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cbacbb2..6ba1ea42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5427,3 +5427,9 @@ The heatmap merges EEG data points with the closest timeline events to show whic ### Features - Minor updates and improvements + +## [0.0.130-rc.7] — 2026-05-02 + +### Features + +- Replace per-app AppleScript window tracking (which triggered a macOS TCC Automation dialog for every new foreground app) with native Accessibility API (AXUIElement) + CoreGraphics calls that require only a single one-time "Accessibility" permission for NeuroSkill. diff --git a/Cargo.lock b/Cargo.lock index 8199a356..819a5be8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2225,12 +2225,6 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "136d3e02915a2cea4d74caa8681e2d44b1c3254bdbf17d11d41d587ff858832c" -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.8.0" @@ -2524,7 +2518,7 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.11.1", "crossterm_winapi", - "derive_more 2.1.1", + "derive_more", "document-features", "mio", "parking_lot", @@ -2578,23 +2572,6 @@ dependencies = [ "phf 0.11.3", ] -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - [[package]] name = "cssparser" version = "0.36.0" @@ -2641,14 +2618,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.117", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "ctr" version = "0.9.2" @@ -2698,7 +2681,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "derive-new", - "derive_more 2.1.1", + "derive_more", "dirs", "embassy-futures", "embassy-time", @@ -2736,7 +2719,7 @@ dependencies = [ "cubecl-macros", "cubecl-runtime", "derive-new", - "derive_more 2.1.1", + "derive_more", "enumset", "float-ord", "half", @@ -2849,7 +2832,7 @@ dependencies = [ "cubecl-common", "cubecl-macros-internal", "derive-new", - "derive_more 2.1.1", + "derive_more", "enumset", "float-ord", "fnv", @@ -2920,7 +2903,7 @@ dependencies = [ "cubecl-common", "cubecl-ir", "derive-new", - "derive_more 2.1.1", + "derive_more", "dirs", "enumset", "foldhash 0.1.5", @@ -2990,7 +2973,7 @@ dependencies = [ "cubecl-runtime", "cubecl-spirv", "derive-new", - "derive_more 2.1.1", + "derive_more", "half", "hashbrown 0.15.5", "log", @@ -3452,19 +3435,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -3646,12 +3616,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set 0.8.0", - "cssparser 0.36.0", + "cssparser", "foldhash 0.2.0", - "html5ever 0.38.0", + "html5ever", "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", + "selectors", + "tendril", ] [[package]] @@ -3724,6 +3694,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -4267,7 +4252,7 @@ dependencies = [ "getrandom 0.3.4", "libm", "rand 0.9.4", - "siphasher 1.0.2", + "siphasher", ] [[package]] @@ -4541,16 +4526,6 @@ dependencies = [ "libc", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.32" @@ -5773,18 +5748,6 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - [[package]] name = "html5ever" version = "0.38.0" @@ -5792,7 +5755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever 0.38.0", + "markup5ever", ] [[package]] @@ -6420,7 +6383,7 @@ dependencies = [ "bytes", "cfg_aliases", "data-encoding", - "derive_more 2.1.1", + "derive_more", "ed25519-dalek", "futures-util", "getrandom 0.3.4", @@ -6471,7 +6434,7 @@ checksum = "55a354e3396b62c14717ee807dfee9a7f43f6dad47e4ac0fd1d49f1ffad14ef0" dependencies = [ "curve25519-dalek", "data-encoding", - "derive_more 2.1.1", + "derive_more", "digest 0.11.0-rc.10", "ed25519-dalek", "n0-error", @@ -6538,7 +6501,7 @@ dependencies = [ "bytes", "cfg_aliases", "data-encoding", - "derive_more 2.1.1", + "derive_more", "getrandom 0.3.4", "hickory-resolver", "http 1.4.0", @@ -6851,9 +6814,9 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.46.3" +version = "0.46.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbe92a2f8b00686061eab5cdcfd6f382c27f2084456e7be90ae9f0fe4a30552a" +checksum = "fc59d2432e047d6090ba1d83c782d0128bd6203857978218f5614dbd3287281f" dependencies = [ "ahash", "bytecount", @@ -6972,18 +6935,6 @@ dependencies = [ "libc", ] -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.14.0", - "selectors 0.24.0", -] - [[package]] name = "lab" version = "0.11.0" @@ -7265,9 +7216,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llama-cpp-4" -version = "0.2.50" +version = "0.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcf0cd079ad2f022bf031670df8ba456c21912563e820aa88e7102e33afb194" +checksum = "848f0db0643df8e38aabe14e6a74d99cb6202b142a83cbffa1fb5bc2e427ecb4" dependencies = [ "enumflags2", "llama-cpp-sys-4", @@ -7277,9 +7228,9 @@ dependencies = [ [[package]] name = "llama-cpp-sys-4" -version = "0.2.50" +version = "0.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca95ff4c86ec27cba44c939e7161bc66a7edef083f7e59186e3bf4e5778a76a" +checksum = "00267ef600935213dbbb794409c16a450a61eb1f66ce723a5179bfaa949d0f6e" dependencies = [ "bindgen 0.72.1", "cc", @@ -7290,9 +7241,9 @@ dependencies = [ [[package]] name = "llmfit-core" -version = "0.9.17" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3d28d163be4423375ed1f7fb0bdc23e40fd1fe56e7a5beb025a9240bb6b978" +checksum = "57796197a65dea17e886c10cd474d8be9dbf755c84d4909064961a62e5fd5f42" dependencies = [ "dirs", "http 1.4.0", @@ -7395,12 +7346,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mac-addr" version = "0.3.0" @@ -7528,20 +7473,6 @@ dependencies = [ "libc", ] -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", -] - [[package]] name = "markup5ever" version = "0.38.0" @@ -7549,7 +7480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "tendril 0.5.0", + "tendril", "web_atoms", ] @@ -7559,17 +7490,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "matchers" version = "0.2.0" @@ -7579,12 +7499,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "matchit" version = "0.8.4" @@ -7871,7 +7785,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.2" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" dependencies = [ "crossbeam-channel", "dpi", @@ -7882,10 +7798,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7976,7 +7892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" dependencies = [ "cfg_aliases", - "derive_more 2.1.1", + "derive_more", "futures-buffered", "futures-lite", "futures-util", @@ -7996,7 +7912,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38795f7932e6e9d1c6e989270ef5b3ff24ebb910e2c9d4bed2d28d8bae3007dc" dependencies = [ - "derive_more 2.1.1", + "derive_more", "n0-error", "n0-future", ] @@ -8201,7 +8117,7 @@ dependencies = [ "atomic-waker", "bytes", "cfg_aliases", - "derive_more 2.1.1", + "derive_more", "js-sys", "libc", "n0-error", @@ -8378,12 +8294,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "nom" version = "7.1.3" @@ -8438,7 +8348,7 @@ checksum = "5c61b72abd670eebc05b5cf720e077b04a3ef3354bc7bc19f1c3524cb424db7b" dependencies = [ "aes-gcm", "bytes", - "derive_more 2.1.1", + "derive_more", "enum-assoc", "fastbloom", "getrandom 0.3.4", @@ -8887,6 +8797,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-media" version = "0.3.2" @@ -9096,8 +9016,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.1", + "block2 0.6.2", "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -9613,26 +9552,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - [[package]] name = "phf" version = "0.11.3" @@ -9654,16 +9573,6 @@ dependencies = [ "serde", ] -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - [[package]] name = "phf_codegen" version = "0.11.3" @@ -9684,24 +9593,6 @@ dependencies = [ "phf_shared 0.13.1", ] -[[package]] -name = "phf_generator" -version = "0.8.0" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.8.6", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.6", -] - [[package]] name = "phf_generator" version = "0.11.3" @@ -9722,20 +9613,6 @@ dependencies = [ "phf_shared 0.13.1", ] -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "phf_macros" version = "0.11.3" @@ -9762,31 +9639,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - [[package]] name = "phf_shared" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] @@ -9795,7 +9654,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] @@ -9865,17 +9724,19 @@ dependencies = [ [[package]] name = "pkarr" -version = "5.0.4" +version = "5.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bfb9143bbba379f246211eb68074d78db9cc048e4c5701f3b0e6cb1ec67ca2" +checksum = "0db5bc018bd8e26cb7e7913623292e5eddd71caf29801ea2b2bd627167044e05" dependencies = [ "base32", "bytes", "cfg_aliases", "document-features", + "ed25519", "ed25519-dalek", "getrandom 0.4.2", "ntimestamp", + "pkcs8", "self_cell", "serde", "simple-dns", @@ -10029,7 +9890,7 @@ checksum = "74748bc706fa6b6aebac6bbe0bbe0de806b384cb5c557ea974f771360a4e3858" dependencies = [ "base64 0.22.1", "bytes", - "derive_more 2.1.1", + "derive_more", "futures-lite", "futures-util", "hyper-util", @@ -10204,12 +10065,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.106" @@ -10830,9 +10685,9 @@ dependencies = [ [[package]] name = "referencing" -version = "0.46.3" +version = "0.46.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e125f10bdcd507598c702daada18c47fe5bfba4d7a9545b015b5d432f7168ca3" +checksum = "cb674900ca31acd75c4aaf63f48e43e719631c0539ea5a9e64163d1296bcb730" dependencies = [ "ahash", "fluent-uri", @@ -11682,24 +11537,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - [[package]] name = "selectors" version = "0.36.1" @@ -11707,15 +11544,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ "bitflags 2.11.1", - "cssparser 0.36.0", - "derive_more 2.1.1", + "cssparser", + "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash 2.1.2", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] @@ -11972,16 +11809,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - [[package]] name = "servo_arc" version = "0.4.3" @@ -12116,12 +11943,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - [[package]] name = "siphasher" version = "1.0.2" @@ -12130,7 +11951,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.6" +version = "0.0.130-rc.7" dependencies = [ "anyhow", "base64 0.22.1", @@ -12482,9 +12303,9 @@ dependencies = [ "http 1.4.0", "serde", "serde_json", - "tao", + "tao 0.34.8", "thiserror 2.0.18", - "wry", + "wry 0.54.4", ] [[package]] @@ -13049,19 +12870,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - [[package]] name = "string_cache" version = "0.9.0" @@ -13074,18 +12882,6 @@ dependencies = [ "precomputed-hash", ] -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "string_cache_codegen" version = "0.6.1" @@ -13419,7 +13215,6 @@ dependencies = [ "dlopen2 0.8.2", "dpi", "gdkwayland-sys", - "gdkx11-sys", "gtk", "jni 0.21.1", "libc", @@ -13439,6 +13234,45 @@ dependencies = [ "windows 0.61.3", "windows-core 0.61.2", "windows-version", +] + +[[package]] +name = "tao" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2 0.8.2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", "x11-dl", ] @@ -13478,9 +13312,9 @@ checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "tauri" -version = "2.10.3" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66" dependencies = [ "anyhow", "bytes", @@ -13530,9 +13364,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.6" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988" dependencies = [ "anyhow", "cargo_toml", @@ -13546,15 +13380,14 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.5" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e" dependencies = [ "base64 0.22.1", "brotli", @@ -13579,9 +13412,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.5" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -13593,9 +13426,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.5.4" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57" dependencies = [ "anyhow", "glob", @@ -13604,7 +13437,6 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -13724,9 +13556,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95" dependencies = [ "cookie", "dpi", @@ -13749,9 +13581,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117" dependencies = [ "gtk", "http 1.4.0", @@ -13763,36 +13595,36 @@ dependencies = [ "percent-encoding", "raw-window-handle", "softbuffer", - "tao", + "tao 0.35.0", "tauri-runtime", "tauri-utils", "url", "webkit2gtk", "webview2-com", "windows 0.61.3", - "wry", + "wry 0.55.0", ] [[package]] name = "tauri-utils" -version = "2.8.3" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", - "html5ever 0.29.1", "http 1.4.0", "infer", "json-patch", - "kuchikiki", "log", "memchr", "phf 0.11.3", + "plist", "proc-macro2", "quote", "regex", @@ -13804,7 +13636,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", "urlpattern", "uuid", @@ -13847,17 +13679,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "tendril" version = "0.5.0" @@ -13936,7 +13757,7 @@ dependencies = [ "phf 0.11.3", "sha2 0.10.9", "signal-hook", - "siphasher 1.0.2", + "siphasher", "terminfo", "termios", "thiserror 1.0.69", @@ -14691,9 +14512,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", "dirs", @@ -14705,10 +14526,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.2", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -15647,8 +15468,8 @@ checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", + "string_cache", + "string_cache_codegen", ] [[package]] @@ -16839,6 +16660,50 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wry" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http 1.4.0", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + [[package]] name = "ws_stream_wasm" version = "0.7.5" @@ -17484,3 +17349,11 @@ dependencies = [ "syn 2.0.117", "winnow 1.0.2", ] + +[[patch.unused]] +name = "muda" +version = "0.17.2" + +[[patch.unused]] +name = "phf_generator" +version = "0.8.0" diff --git a/changes/releases/0.0.130-rc.7.md b/changes/releases/0.0.130-rc.7.md new file mode 100644 index 00000000..9b8e30de --- /dev/null +++ b/changes/releases/0.0.130-rc.7.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.7] — 2026-05-02 + +### Features + +- Replace per-app AppleScript window tracking (which triggered a macOS TCC Automation dialog for every new foreground app) with native Accessibility API (AXUIElement) + CoreGraphics calls that require only a single one-time "Accessibility" permission for NeuroSkill. diff --git a/package.json b/package.json index 1d0fde53..a2fe704d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.6", + "version": "0.0.130-rc.7", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 488ee310..ff475c1c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.6" +version = "0.0.130-rc.7" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9f26e026..529d9b6b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.6", + "version": "0.0.130-rc.7", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From 6193eb0728ba0b4fcb0d4cd0093f35080d28db8b Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 20:26:20 -0400 Subject: [PATCH 13/98] cargo deny --- deny.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deny.toml b/deny.toml index ffaa52af..74320389 100644 --- a/deny.toml +++ b/deny.toml @@ -25,6 +25,11 @@ ignore = [ { id = "RUSTSEC-2024-0415", reason = "transitive via tauri/libappindicator, not directly upgradeable" }, # lru 0.12.5 — IterMut unsoundness, patched in >=0.16.3. # Pulled transitively; not directly used by workspace code paths that call IterMut. + + # hickory-proto 0.25.2 — pulled transitively via iroh → hickory-resolver. + # Cannot upgrade until iroh ships a version that depends on hickory-proto >=0.25.3. + { id = "RUSTSEC-2026-0118", reason = "transitive via iroh/hickory-resolver, not directly upgradeable" }, + { id = "RUSTSEC-2026-0119", reason = "transitive via iroh/hickory-resolver, not directly upgradeable" }, ] # ── Licenses ────────────────────────────────────────────────────────────────── From 3295057b027ab711d2a9dd340d658f21575f9677 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 20:31:34 -0400 Subject: [PATCH 14/98] updated llm catalog --- src-tauri/llm_catalog.json | 271 +++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) diff --git a/src-tauri/llm_catalog.json b/src-tauri/llm_catalog.json index 4e605321..6d803cb6 100644 --- a/src-tauri/llm_catalog.json +++ b/src-tauri/llm_catalog.json @@ -445,6 +445,33 @@ "is_mmproj": false, "params_b": 4.0, "max_context_length": 131072 + }, + "mistral-medium-3.5-128b": { + "name": "Mistral Medium 3.5 128B", + "description": "Mistral AI's 128B parameter medium-class model with strong reasoning and chat quality. Requires 64 GB+ unified memory or VRAM for Q4 quants.", + "repo": "bartowski/mistralai_Mistral-Medium-3.5-128B-GGUF", + "tags": [ + "chat", + "reasoning", + "large" + ], + "is_mmproj": false, + "params_b": 128.0, + "max_context_length": 131072 + }, + "nemotron-3-nano-omni-30b": { + "name": "NVIDIA Nemotron-3 Nano Omni 30B", + "description": "NVIDIA's multimodal MoE model (30B, 3B active) with video, audio, image, and text understanding. Supports reasoning, tool calling, OCR, and GUI automation.", + "repo": "unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF", + "tags": [ + "chat", + "reasoning", + "multimodal", + "moe" + ], + "is_mmproj": true, + "params_b": 30.0, + "max_context_length": 131072 } }, "models": [ @@ -4073,6 +4100,250 @@ "size_gb": 35.81, "description": "8-bit with important layers at higher precision", "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q2_K.gguf", + "quant": "Q2_K", + "size_gb": 49.86, + "description": "Very low quality; smallest single-file download", + "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ2_M.gguf", + "quant": "IQ2_M", + "size_gb": 48.61, + "description": "Low quality imatrix; surprisingly usable", + "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q2_K_L/mistralai_Mistral-Medium-3.5-128B-Q2_K_L-00001-of-00002.gguf", + "quant": "Q2_K_L", + "size_gb": 51.43, + "description": "Q8_0 embed/output weights; very low quality", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q2_K_L/mistralai_Mistral-Medium-3.5-128B-Q2_K_L-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q2_K_L/mistralai_Mistral-Medium-3.5-128B-Q2_K_L-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ3_M/mistralai_Mistral-Medium-3.5-128B-IQ3_M-00001-of-00002.gguf", + "quant": "IQ3_M", + "size_gb": 59.53, + "description": "Medium-low quality imatrix; decent performance", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-IQ3_M/mistralai_Mistral-Medium-3.5-128B-IQ3_M-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-IQ3_M/mistralai_Mistral-Medium-3.5-128B-IQ3_M-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q3_K_M/mistralai_Mistral-Medium-3.5-128B-Q3_K_M-00001-of-00002.gguf", + "quant": "Q3_K_M", + "size_gb": 63.28, + "description": "Low quality; good for limited RAM/VRAM", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q3_K_M/mistralai_Mistral-Medium-3.5-128B-Q3_K_M-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q3_K_M/mistralai_Mistral-Medium-3.5-128B-Q3_K_M-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ4_XS/mistralai_Mistral-Medium-3.5-128B-IQ4_XS-00001-of-00002.gguf", + "quant": "IQ4_XS", + "size_gb": 69.14, + "description": "Decent quality imatrix; smaller than Q4_K_S", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-IQ4_XS/mistralai_Mistral-Medium-3.5-128B-IQ4_XS-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-IQ4_XS/mistralai_Mistral-Medium-3.5-128B-IQ4_XS-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q4_K_S/mistralai_Mistral-Medium-3.5-128B-Q4_K_S-00001-of-00002.gguf", + "quant": "Q4_K_S", + "size_gb": 73.02, + "description": "Good quality with space savings", + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q4_K_S/mistralai_Mistral-Medium-3.5-128B-Q4_K_S-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q4_K_S/mistralai_Mistral-Medium-3.5-128B-Q4_K_S-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q4_K_M/mistralai_Mistral-Medium-3.5-128B-Q4_K_M-00001-of-00002.gguf", + "quant": "Q4_K_M", + "size_gb": 78.41, + "description": "Recommended -- best quality/size tradeoff", + "recommended": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q4_K_M/mistralai_Mistral-Medium-3.5-128B-Q4_K_M-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q4_K_M/mistralai_Mistral-Medium-3.5-128B-Q4_K_M-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q5_K_M/mistralai_Mistral-Medium-3.5-128B-Q5_K_M-00001-of-00003.gguf", + "quant": "Q5_K_M", + "size_gb": 91.11, + "description": "High quality; >= 96 GB unified memory", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q5_K_M/mistralai_Mistral-Medium-3.5-128B-Q5_K_M-00001-of-00003.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q5_K_M/mistralai_Mistral-Medium-3.5-128B-Q5_K_M-00002-of-00003.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q5_K_M/mistralai_Mistral-Medium-3.5-128B-Q5_K_M-00003-of-00003.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q6_K/mistralai_Mistral-Medium-3.5-128B-Q6_K-00001-of-00003.gguf", + "quant": "Q6_K", + "size_gb": 107.8, + "description": "Very high quality, near perfect; >= 112 GB unified memory", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q6_K/mistralai_Mistral-Medium-3.5-128B-Q6_K-00001-of-00003.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q6_K/mistralai_Mistral-Medium-3.5-128B-Q6_K-00002-of-00003.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q6_K/mistralai_Mistral-Medium-3.5-128B-Q6_K-00003-of-00003.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q8_0/mistralai_Mistral-Medium-3.5-128B-Q8_0-00001-of-00004.gguf", + "quant": "Q8_0", + "size_gb": 132.85, + "description": "Effectively lossless; very large", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q8_0/mistralai_Mistral-Medium-3.5-128B-Q8_0-00001-of-00004.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q8_0/mistralai_Mistral-Medium-3.5-128B-Q8_0-00002-of-00004.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q8_0/mistralai_Mistral-Medium-3.5-128B-Q8_0-00003-of-00004.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q8_0/mistralai_Mistral-Medium-3.5-128B-Q8_0-00004-of-00004.gguf" + ] + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-IQ2_M.gguf", + "quant": "IQ2_M", + "size_gb": 18.5, + "description": "Ultra-low quality imatrix; smallest useful option", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q2_K_XL.gguf", + "quant": "Q2_K_XL", + "size_gb": 18.5, + "description": "Ultra-low quality; Q8_0 embed/output weights", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-IQ3_S.gguf", + "quant": "IQ3_S", + "size_gb": 18.82, + "description": "Low quality imatrix", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-IQ3_XXS.gguf", + "quant": "IQ3_XXS", + "size_gb": 19.46, + "description": "Low quality imatrix", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-IQ4_XS.gguf", + "quant": "IQ4_XS", + "size_gb": 19.54, + "description": "Decent quality; smaller than Q4_K_S", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-IQ4_NL.gguf", + "quant": "IQ4_NL", + "size_gb": 19.54, + "description": "Decent quality; good for ARM CPU inference", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-MXFP4_MOE.gguf", + "quant": "MXFP4_MOE", + "size_gb": 21.73, + "description": "MX FP4 MoE quant -- fast on supported hardware" + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q4_K_S.gguf", + "quant": "Q4_K_S", + "size_gb": 23.05, + "description": "Good quality with space savings" + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 23.89, + "description": "Recommended -- best quality/size tradeoff", + "recommended": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q5_K_S.gguf", + "quant": "Q5_K_S", + "size_gb": 24.8, + "description": "High quality", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q5_K_M.gguf", + "quant": "Q5_K_M", + "size_gb": 29.0, + "description": "High quality; >= 32 GB VRAM", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q6_K.gguf", + "quant": "Q6_K", + "size_gb": 33.59, + "description": "Very high quality, near perfect", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 33.59, + "description": "Effectively lossless; very large", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "mmproj-BF16.gguf", + "quant": "BF16", + "size_gb": 1.59, + "description": "Nemotron-3 Nano Omni vision/audio projector -- BF16 (recommended)", + "recommended": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "mmproj-F16.gguf", + "quant": "F16", + "size_gb": 1.59, + "description": "Nemotron-3 Nano Omni vision/audio projector -- FP16" } ] } From d59efabdec1c6a850b934d1d2b4b7f09f3e1462c Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 20:39:19 -0400 Subject: [PATCH 15/98] fix tty --- crates/skill-daemon/src/main.rs | 11 ++++++++++- crates/skill-daemon/src/routes/settings.rs | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/skill-daemon/src/main.rs b/crates/skill-daemon/src/main.rs index ab20b261..51811ef8 100644 --- a/crates/skill-daemon/src/main.rs +++ b/crates/skill-daemon/src/main.rs @@ -52,7 +52,16 @@ fn main() -> anyhow::Result<()> { let args: Vec = std::env::args().collect(); #[cfg(unix)] if args.get(1).map(String::as_str) == Some("tty") { - return tty::run(&args[2..]); + // Exit 126 signals the shell hook that the PTY shim failed to start + // (not a tty, openpty failed, etc.) so the hook can fall through to a + // plain shell instead of closing the terminal. Any other exit code is + // the inner shell's own exit code and is forwarded as-is (tty::run + // calls std::process::exit internally on success). + if let Err(e) = tty::run(&args[2..]) { + eprintln!("skill-daemon tty: {e:#}"); + std::process::exit(126); + } + return Ok(()); } daemon_main() } diff --git a/crates/skill-daemon/src/routes/settings.rs b/crates/skill-daemon/src/routes/settings.rs index fa35c7cb..b8ff445b 100644 --- a/crates/skill-daemon/src/routes/settings.rs +++ b/crates/skill-daemon/src/routes/settings.rs @@ -1300,9 +1300,16 @@ fi # fresh PTY and proxies stdin/stdout while forwarding SIGWINCH correctly. # Log path is chosen internally (under ~/.skill/terminal-logs/) so it never # appears in argv or the terminal title. Set NEUROSKILL_RECORDING=1 to opt out. +# We intentionally avoid `exec` here: if the shim exits with code 126 it +# means startup failed (not a tty, can't open PTY, etc.) and we fall through +# to a plain interactive shell. For any other exit code (normal user exit, +# Ctrl-D, …) we forward it and close this shell too. if [[ -z "$NEUROSKILL_RECORDING" && -x "{daemon_path}" ]]; then export NEUROSKILL_RECORDING=1 - exec "{daemon_path}" tty + "{daemon_path}" tty + _ns_rc=$? + unset NEUROSKILL_RECORDING + [[ $_ns_rc -ne 126 ]] && exit $_ns_rc fi "# ), @@ -1356,9 +1363,13 @@ fi # Session recording via the daemon's `tty` PTY shim (forwards SIGWINCH). # Log path chosen internally — nothing leaks into argv or the tab title. +# See zsh block above for the fallback-on-failure rationale. if [[ -z "$NEUROSKILL_RECORDING" && -x "{daemon_path}" ]]; then export NEUROSKILL_RECORDING=1 - exec "{daemon_path}" tty + "{daemon_path}" tty + _ns_rc=$? + unset NEUROSKILL_RECORDING + [[ $_ns_rc -ne 126 ]] && exit $_ns_rc fi "# ), From 28649e71eb5996014f41ede972bda269bd9f3994 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 20:40:45 -0400 Subject: [PATCH 16/98] safety checks --- crates/skill-daemon/src/activity.rs | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/crates/skill-daemon/src/activity.rs b/crates/skill-daemon/src/activity.rs index 5baf5df1..31bb49a2 100644 --- a/crates/skill-daemon/src/activity.rs +++ b/crates/skill-daemon/src/activity.rs @@ -238,11 +238,14 @@ fn ax_poll_active_window() -> Option { // ── Step 2: window title + document path via AXUIElement ───────────────── // One "Accessibility" permission covers all apps — no per-app dialogs. + // SAFETY: All CF/AX objects are null-checked before use; owned refs are + // released via CFRelease before the block exits. let (window_title, document_path) = unsafe { /// Convert a non-null CFStringRef to a Rust `String`. /// /// SAFETY: `s` must be a valid, non-null CFStringRef. unsafe fn cfstr_to_string(s: CFStringRef, enc: u32) -> String { + // SAFETY: upheld by the caller (see fn-level doc). unsafe { let len = CFStringGetLength(s); let max = CFStringGetMaximumSizeForEncoding(len, enc) + 1; @@ -255,21 +258,11 @@ fn ax_poll_active_window() -> Option { } } - let key_focused_win = CFStringCreateWithCString( - std::ptr::null(), - b"AXFocusedWindow\0".as_ptr() as *const c_char, - KCF_STRING_ENCODING_UTF8, - ); - let key_title = CFStringCreateWithCString( - std::ptr::null(), - b"AXTitle\0".as_ptr() as *const c_char, - KCF_STRING_ENCODING_UTF8, - ); - let key_document = CFStringCreateWithCString( - std::ptr::null(), - b"AXDocument\0".as_ptr() as *const c_char, - KCF_STRING_ENCODING_UTF8, - ); + let key_focused_win = + CFStringCreateWithCString(std::ptr::null(), c"AXFocusedWindow".as_ptr(), KCF_STRING_ENCODING_UTF8); + let key_title = CFStringCreateWithCString(std::ptr::null(), c"AXTitle".as_ptr(), KCF_STRING_ENCODING_UTF8); + let key_document = + CFStringCreateWithCString(std::ptr::null(), c"AXDocument".as_ptr(), KCF_STRING_ENCODING_UTF8); let app_ax = AXUIElementCreateApplication(pid); @@ -464,6 +457,7 @@ fn poll_secondary_windows() -> Vec { /// /// SAFETY: `s` must be a NUL-terminated byte slice. Caller must CFRelease. unsafe fn cfstr(s: &[u8]) -> CFStringRef { + // SAFETY: upheld by caller (NUL-terminated slice, result CFRelease'd). unsafe { CFStringCreateWithCString(std::ptr::null(), s.as_ptr() as *const c_char, K_CF_STRING_ENCODING_UTF8) } @@ -477,6 +471,7 @@ fn poll_secondary_windows() -> Vec { return None; } let mut v: i64 = 0; + // SAFETY: `n` is non-null and a valid CFNumber; `v` is a local i64. unsafe { if CFNumberGetValue(n, K_CF_NUMBER_SINT32_TYPE, &mut v) { Some(v as i32) @@ -494,6 +489,7 @@ fn poll_secondary_windows() -> Vec { return None; } let mut v: i64 = 0; + // SAFETY: `n` is non-null and a valid CFNumber; reinterpret bits as f64. unsafe { // CFNumberGetValue writes the numeric bits; reinterpret as f64. if CFNumberGetValue(n, K_CF_NUMBER_FLOAT64_TYPE, &mut v) { @@ -508,6 +504,7 @@ fn poll_secondary_windows() -> Vec { /// /// SAFETY: `s` must be a valid, non-null CFStringRef. unsafe fn cfstr_to_string(s: CFStringRef) -> String { + // SAFETY: upheld by the caller (see fn-level doc). unsafe { let len = CFStringGetLength(s); let max = CFStringGetMaximumSizeForEncoding(len, K_CF_STRING_ENCODING_UTF8) + 1; From 6240838d4253ed0a852c70c664126b02fc75388e Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 20:49:49 -0400 Subject: [PATCH 17/98] fix windows CI --- .github/workflows/release-windows.yml | 3 +-- changes/unreleased/auto-git-log.md | 3 +++ scripts/ci.mjs | 11 ++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 changes/unreleased/auto-git-log.md diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 5a00b336..c8111389 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -910,9 +910,8 @@ jobs: # ── Discord notification ────────────────────────────────────────────────── - name: Notify Discord of release - if: always() && steps.version_meta.outputs.dry_run != 'true' + if: always() && steps.version_meta.outputs.is_release == 'true' shell: bash - env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} run: >- node scripts/ci.mjs discord-notify diff --git a/changes/unreleased/auto-git-log.md b/changes/unreleased/auto-git-log.md new file mode 100644 index 00000000..6da3566d --- /dev/null +++ b/changes/unreleased/auto-git-log.md @@ -0,0 +1,3 @@ +### Features + +- Minor updates and improvements diff --git a/scripts/ci.mjs b/scripts/ci.mjs index 028a7833..dae2ceea 100644 --- a/scripts/ci.mjs +++ b/scripts/ci.mjs @@ -323,7 +323,7 @@ function ensureRcLatestRelease() { ], { check: true }); } -function cmdDiscordNotify(args) { +async function cmdDiscordNotify(args) { const webhook = process.env.DISCORD_WEBHOOK_URL; if (!webhook) { console.log("⚠ DISCORD_WEBHOOK_URL not set, skipping."); @@ -359,8 +359,13 @@ function cmdDiscordNotify(args) { }); try { - const r = spawnSync("curl", ["-sf", "-X", "POST", webhook, "-H", "Content-Type: application/json", "-d", payload], { stdio: "pipe", encoding: "utf8" }); - if (r.status !== 0) throw new Error(`curl exited ${r.status}`); + // Use built-in fetch (Node 18+) to avoid platform-specific curl quoting issues. + const r = await fetch(webhook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); } catch { console.log("⚠ Discord notification failed (non-fatal)."); } From 95c26e4a752163a16442053b0f968dc11badba37 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 20:53:12 -0400 Subject: [PATCH 18/98] fix(cargo): remove obsolete muda and phf_generator patches Both patches are no longer used in the crate graph: - muda 0.17.2 patch: dependency has been upgraded to muda 0.19.1 - phf_generator 0.8.0 patch: dependency no longer resolves to 0.8.0 Cargo emits 'warning: patch ... was not used in the crate graph' for each of these, which the bump.js preflight treats as a hard failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.toml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2a2d7dd1..7cd26a18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ [workspace] resolver = "2" -exclude = ["patches/muda-0.17.2"] +exclude = [] members = [ "src-tauri", "crates/skill-autostart", @@ -68,8 +68,6 @@ members = [ [patch.crates-io] cubek-matmul = { git = "https://github.com/eugenehp/cubek.git", branch = "cubek-matmul", package = "cubek-matmul" } btleplug = { git = "https://github.com/eugenehp/btleplug.git", branch = "imrpoved_mac_version" } -# Fix muda ZeroWidth panic in to_png() on macOS (upstream bug in 0.17.2) -muda = { path = "patches/muda-0.17.2" } # Fix glib VariantStrIter unsoundness (GHSA-wrw7-89jp-8q8g) — &p → &mut p # All gtk-rs-core crates must come from the same source to keep -sys types aligned. glib = { git = "https://github.com/eugenehp/gtk-rs-core.git", branch = "0.18-patched" } @@ -78,8 +76,6 @@ gobject-sys = { git = "https://github.com/eugenehp/gtk-rs-core.git", branch gio = { git = "https://github.com/eugenehp/gtk-rs-core.git", branch = "0.18-patched" } gio-sys = { git = "https://github.com/eugenehp/gtk-rs-core.git", branch = "0.18-patched" } glib-macros = { git = "https://github.com/eugenehp/gtk-rs-core.git", branch = "0.18-patched" } -# Fix rand 0.7.3 vulnerability (GHSA-2qph-qpvm-2qf7) pulled in by selectors → phf_codegen → phf_generator -phf_generator = { path = "patches/phf_generator-0.8.0" } # burn-mlx on crates.io (0.1.2) targets burn 0.16; the git main branch supports # burn 0.20 which we use. This patch also covers fast-umap's transitive dep. burn-mlx = { git = "https://github.com/eidola-ai/burn-mlx", branch = "burn-0-20" } From 24f9ca5a68573deecb26376ec377f572d3f81f03 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 20:54:20 -0400 Subject: [PATCH 19/98] fixed cargo clippy --- Cargo.lock | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 819a5be8..cecf1f0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17349,11 +17349,3 @@ dependencies = [ "syn 2.0.117", "winnow 1.0.2", ] - -[[patch.unused]] -name = "muda" -version = "0.17.2" - -[[patch.unused]] -name = "phf_generator" -version = "0.8.0" From 5002c1473850560ee5540bdb69e2fc519c3153d9 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 20:56:23 -0400 Subject: [PATCH 20/98] 0.0.130-rc.8 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- .../auto-git-log.md => releases/0.0.130-rc.8.md} | 2 ++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 12 insertions(+), 4 deletions(-) rename changes/{unreleased/auto-git-log.md => releases/0.0.130-rc.8.md} (58%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba1ea42..c63b786b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5433,3 +5433,9 @@ The heatmap merges EEG data points with the closest timeline events to show whic ### Features - Replace per-app AppleScript window tracking (which triggered a macOS TCC Automation dialog for every new foreground app) with native Accessibility API (AXUIElement) + CoreGraphics calls that require only a single one-time "Accessibility" permission for NeuroSkill. + +## [0.0.130-rc.8] — 2026-05-02 + +### Features + +- Minor updates and improvements diff --git a/Cargo.lock b/Cargo.lock index cecf1f0d..e020dfac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11951,7 +11951,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.7" +version = "0.0.130-rc.8" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/changes/unreleased/auto-git-log.md b/changes/releases/0.0.130-rc.8.md similarity index 58% rename from changes/unreleased/auto-git-log.md rename to changes/releases/0.0.130-rc.8.md index 6da3566d..710ed735 100644 --- a/changes/unreleased/auto-git-log.md +++ b/changes/releases/0.0.130-rc.8.md @@ -1,3 +1,5 @@ +## [0.0.130-rc.8] — 2026-05-02 + ### Features - Minor updates and improvements diff --git a/package.json b/package.json index a2fe704d..d82cb600 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.7", + "version": "0.0.130-rc.8", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ff475c1c..38942f17 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.7" +version = "0.0.130-rc.8" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 529d9b6b..3aa5e16c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.7", + "version": "0.0.130-rc.8", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From 5968de69af96936a40526d58b1e4570ae2148a4a Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 21:06:53 -0400 Subject: [PATCH 21/98] 0.0.130-rc.9 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- changes/releases/0.0.130-rc.9.md | 5 +++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 changes/releases/0.0.130-rc.9.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c63b786b..6be20c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5439,3 +5439,9 @@ The heatmap merges EEG data points with the closest timeline events to show whic ### Features - Minor updates and improvements + +## [0.0.130-rc.9] — 2026-05-02 + +### Features + +- Minor updates and improvements diff --git a/Cargo.lock b/Cargo.lock index e020dfac..59c6227d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11951,7 +11951,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.8" +version = "0.0.130-rc.9" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/changes/releases/0.0.130-rc.9.md b/changes/releases/0.0.130-rc.9.md new file mode 100644 index 00000000..2e6109e1 --- /dev/null +++ b/changes/releases/0.0.130-rc.9.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.9] — 2026-05-02 + +### Features + +- Minor updates and improvements diff --git a/package.json b/package.json index d82cb600..47a978e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.8", + "version": "0.0.130-rc.9", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 38942f17..d1f282d7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.8" +version = "0.0.130-rc.9" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3aa5e16c..7fe31e97 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.8", + "version": "0.0.130-rc.9", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From 17105317cef321d6ee91f1035bae29f64604ad6e Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 21:18:19 -0400 Subject: [PATCH 22/98] fix windows ci --- .github/workflows/release-windows.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index c8111389..deac7f58 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -912,6 +912,7 @@ jobs: - 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 From b192d508c554eacbc66c1defbb3af47d636ce3da Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 21:19:35 -0400 Subject: [PATCH 23/98] 0.0.130-rc.10 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- changes/releases/0.0.130-rc.10.md | 5 +++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 changes/releases/0.0.130-rc.10.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be20c96..88111bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5397,6 +5397,12 @@ The heatmap merges EEG data points with the closest timeline events to show whic - **Update kittentts to 0.4.1**: TTS engine update. - **Add grayscale 0.0.1**: macOS grayscale display control for DND mode. +## [0.0.130-rc.10] — 2026-05-02 + +### Features + +- fix windows ci + ## [0.0.130-rc.2] — 2026-04-29 ### Features diff --git a/Cargo.lock b/Cargo.lock index 59c6227d..bf6ed607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11951,7 +11951,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.9" +version = "0.0.130-rc.10" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/changes/releases/0.0.130-rc.10.md b/changes/releases/0.0.130-rc.10.md new file mode 100644 index 00000000..f0623da0 --- /dev/null +++ b/changes/releases/0.0.130-rc.10.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.10] — 2026-05-02 + +### Features + +- fix windows ci diff --git a/package.json b/package.json index 47a978e0..c44af60a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.9", + "version": "0.0.130-rc.10", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d1f282d7..e1ad47e3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.9" +version = "0.0.130-rc.10" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7fe31e97..b5a33954 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.9", + "version": "0.0.130-rc.10", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From b617685e1975a4587e796c57f3444cdf92a03f7d Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 23:27:36 -0400 Subject: [PATCH 24/98] =?UTF-8?q?1.=20GPU=20f16=20SHADER=5FF16=20panic=20?= =?UTF-8?q?=E2=86=92=20`catch=5Funwind`=20in=20worker.rs=202.=20Search=20B?= =?UTF-8?q?ETWEEN=20mismatch=20=E2=86=92=20`DualTimestampRange`=20in=20ski?= =?UTF-8?q?ll-commands=203.=20`date=5Ffrom=5Fts`=20wrong=20date=20folder?= =?UTF-8?q?=20for=2017-digit=20=E2=86=92=20digit-count=20dispatch=20in=20s?= =?UTF-8?q?kill-commands=204.=20UMAP=20`load=5Fembeddings=5Frange`=20same?= =?UTF-8?q?=20BETWEEN=20+=20`ts/1000`=20bugs=20=E2=86=92=20skill-router=20?= =?UTF-8?q?5.=20Reembed=20`extract=5Fepoch=5Fsamples`=20given=20garbage=20?= =?UTF-8?q?seconds=20=E2=86=92=20`epoch=5Fts=5Fto=5Funix`=20in=20settings?= =?UTF-8?q?=5Fexg.rs=206.=20Session=20epoch-count=20always=20`None`=20in?= =?UTF-8?q?=20history=20UI=20=E2=86=92=20`epoch=5Fts=5Fto=5Funix`=20in=20s?= =?UTF-8?q?kill-history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/skill-commands/src/lib.rs | 111 +++++++++++++----- crates/skill-daemon/src/embed/worker.rs | 21 +++- .../skill-daemon/src/routes/settings_exg.rs | 2 +- crates/skill-data/src/lib.rs | 3 + crates/skill-history/src/lib.rs | 2 +- crates/skill-router/src/lib.rs | 36 ++++-- 6 files changed, 127 insertions(+), 48 deletions(-) diff --git a/crates/skill-commands/src/lib.rs b/crates/skill-commands/src/lib.rs index 1d272de5..4bc4aaf6 100644 --- a/crates/skill-commands/src/lib.rs +++ b/crates/skill-commands/src/lib.rs @@ -38,7 +38,7 @@ pub mod graph; pub use graph::{dot_edge_label, dot_esc, dot_node_label, generate_dot, generate_svg, generate_svg_3d, SvgLabels}; // Re-export shared utilities so downstream crates keep compiling. -pub use skill_data::util::{fmt_unix_utc, ts_to_unix, unix_to_ts, MutexExt}; +pub use skill_data::util::{fmt_unix_utc, ts_to_unix, unix_to_ts, DualTimestampRange, MutexExt}; /// Shared, optionally-ready global HNSW index. /// @@ -244,15 +244,21 @@ struct RawEmb { embedding: Vec, } -/// Read every embedding in [start_ts, end_ts] from a single day's SQLite. -fn read_embeddings_in_range(db_path: &Path, start_ts: i64, end_ts: i64) -> Vec { - read_embeddings_in_range_filtered(db_path, start_ts, end_ts, None) +/// Read every embedding in [start_utc, end_utc] from a single day's SQLite. +/// +/// Uses [`DualTimestampRange`] so it matches all three timestamp formats that +/// may appear in the `embeddings` table: +/// - Unix milliseconds (13 digits) +/// - `YYYYMMDDHHmmss` (14 digits, pre-Apr 2026) +/// - `YYYYMMDDHHmmss × 1000` (17 digits, Apr 2026+) +fn read_embeddings_in_range(db_path: &Path, start_utc: u64, end_utc: u64) -> Vec { + read_embeddings_in_range_filtered(db_path, start_utc, end_utc, None) } fn read_embeddings_in_range_filtered( db_path: &Path, - start_ts: i64, - end_ts: i64, + start_utc: u64, + end_utc: u64, device_filter: Option<&str>, ) -> Vec { let conn = match skill_data::util::open_readonly(db_path) { @@ -263,30 +269,46 @@ fn read_embeddings_in_range_filtered( } }; + let r = skill_data::util::DualTimestampRange::from_unix_secs(start_utc, end_utc); + let ts_where = skill_data::util::DualTimestampRange::WHERE_CLAUSE; + let (sql, params): (String, Vec>) = if let Some(dev) = device_filter { ( - "SELECT hnsw_id, timestamp, eeg_embedding \ - FROM embeddings \ - WHERE timestamp BETWEEN ?1 AND ?2 \ - AND length(eeg_embedding) >= 4 \ - AND device_name = ?3 \ - ORDER BY timestamp" - .into(), + format!( + "SELECT hnsw_id, timestamp, eeg_embedding \ + FROM embeddings \ + WHERE ({ts_where}) \ + AND length(eeg_embedding) >= 4 \ + AND device_name = ?7 \ + ORDER BY timestamp" + ), vec![ - Box::new(start_ts) as Box, - Box::new(end_ts), + Box::new(r.unix_ms_start) as Box, + Box::new(r.unix_ms_end), + Box::new(r.dt14_start), + Box::new(r.dt14_end), + Box::new(r.dt17_start), + Box::new(r.dt17_end), Box::new(dev.to_string()), ], ) } else { ( - "SELECT hnsw_id, timestamp, eeg_embedding \ - FROM embeddings \ - WHERE timestamp BETWEEN ?1 AND ?2 \ - AND length(eeg_embedding) >= 4 \ - ORDER BY timestamp" - .into(), - vec![Box::new(start_ts) as Box, Box::new(end_ts)], + format!( + "SELECT hnsw_id, timestamp, eeg_embedding \ + FROM embeddings \ + WHERE ({ts_where}) \ + AND length(eeg_embedding) >= 4 \ + ORDER BY timestamp" + ), + vec![ + Box::new(r.unix_ms_start) as Box, + Box::new(r.unix_ms_end), + Box::new(r.dt14_start), + Box::new(r.dt14_end), + Box::new(r.dt17_start), + Box::new(r.dt17_end), + ], ) }; @@ -315,8 +337,24 @@ fn read_embeddings_in_range_filtered( } /// Derive the `YYYYMMDD` date string from a `YYYYMMDDHHmmss` timestamp integer. +/// Extract a `YYYYMMDD` directory name from any embeddings-table timestamp. +/// +/// Handles all three historical formats: +/// - 17-digit `YYYYMMDDHHmmss × 1000` (e.g. `20260427034308000`) → divide by 10^9 +/// - 14-digit `YYYYMMDDHHmmss` (e.g. `20260427034308`) → divide by 10^6 +/// - 13-digit Unix milliseconds (e.g. `1777362376000`) → convert via calendar fn date_from_ts(ts: i64) -> String { - format!("{}", ts / 1_000_000) + let digits = if ts > 0 { (ts as f64).log10() as u32 + 1 } else { 0 }; + match digits { + 17 => format!("{}", ts / 1_000_000_000), // YYYYMMDDHHmmss×1000 → YYYYMMDD + 14 => format!("{}", ts / 1_000_000), // YYYYMMDDHHmmss → YYYYMMDD + _ => { + // Unix milliseconds: convert to Unix secs, then to YYYYMMDDHHmmss, take date part. + let secs = (ts.max(0) / 1000) as u64; + let dt14 = skill_data::util::unix_to_ts(secs); + format!("{}", dt14 / 1_000_000) + } + } } /// Convert a database timestamp (ms) to Unix seconds. @@ -464,12 +502,10 @@ pub fn search_embeddings_in_range_for( global_index: GlobalIndexHandle, model_backend: &str, ) -> SearchResult { - let start_ts = (start_utc as i64) * 1000; - let end_ts = (end_utc as i64) * 1000; let labels_db = skill_dir.join(LABELS_FILE); let date_dirs = list_date_dirs(skill_dir); - // ── Collect query embeddings from days that overlap [start_ts, end_ts] ──── + // ── Collect query embeddings from days that overlap [start_utc, end_utc] ──── // Store index into `date_dirs` to avoid cloning String/PathBuf per embedding. let mut query_embs: Vec<(usize, RawEmb)> = Vec::new(); for (dd_idx, (date, dir)) in date_dirs.iter().enumerate() { @@ -477,7 +513,7 @@ pub fn search_embeddings_in_range_for( if !db_path.exists() { continue; } - let embs = read_embeddings_in_range(&db_path, start_ts, end_ts); + let embs = read_embeddings_in_range(&db_path, start_utc, end_utc); if !embs.is_empty() { eprintln!("[search] {} query embs from {}", embs.len(), date); } @@ -648,8 +684,6 @@ pub fn stream_search_inner_for( emit: &dyn Fn(SearchProgress), model_backend: &str, ) { - let start_ts = (start_utc as i64) * 1000; - let end_ts = (end_utc as i64) * 1000; let labels_db = skill_dir.join(LABELS_FILE); let date_dirs = list_date_dirs(skill_dir); @@ -681,7 +715,7 @@ pub fn stream_search_inner_for( if !db_path.exists() { continue; } - let embs = read_embeddings_in_range_filtered(&db_path, start_ts, end_ts, device_filter); + let embs = read_embeddings_in_range_filtered(&db_path, start_utc, end_utc, device_filter); let _ = date; // used only for db_path for emb in embs { query_embs.push((dd_idx, emb)); @@ -1460,7 +1494,7 @@ mod tests { #[test] fn date_from_ts_extracts_date_prefix() { - // ts format is YYYYMMDDHHmmss — dividing by 1_000_000 gives YYYYMMDD + // 14-digit YYYYMMDDHHmmss → divide by 10^6 assert_eq!(date_from_ts(20260414143000), "20260414"); } @@ -1469,6 +1503,21 @@ mod tests { assert_eq!(date_from_ts(19700101000000), "19700101"); } + #[test] + fn date_from_ts_17digit() { + // 17-digit YYYYMMDDHHmmss×1000 (current stored format) → divide by 10^9 + assert_eq!(date_from_ts(20260427034308000), "20260427"); + } + + #[test] + fn date_from_ts_unix_ms() { + // Unix milliseconds (13 digits) → calendar conversion + // 1777362376000 ms = 2026-04-26 ... UTC + let result = date_from_ts(1777362376000); + assert!(result.starts_with("2026"), "expected 2026 date, got {result}"); + assert_eq!(result.len(), 8, "YYYYMMDD must be 8 chars, got {result}"); + } + // ── ts_ms_to_unix ──────────────────────────────────────────────────── #[test] diff --git a/crates/skill-daemon/src/embed/worker.rs b/crates/skill-daemon/src/embed/worker.rs index 3e6cc7d9..95af9200 100644 --- a/crates/skill-daemon/src/embed/worker.rs +++ b/crates/skill-daemon/src/embed/worker.rs @@ -628,11 +628,24 @@ fn load_encoder(config: &ExgModelConfig, _skill_dir: &Path) -> Option { } #[cfg(feature = "embed-zuna-gpu-f16")] if try_gpu { - if let Some(s) = load_zuna_gpu_f16(config) { - info!("ZUNA GPU f16 encoder loaded"); - return Some(Encoder::ZunaGpuF16(Box::new(s))); + // Wrap in catch_unwind: on adapters where wgpu does not expose + // SHADER_F16 (e.g. Vulkan/DX12 without storageInputOutput16), + // burn's naga validation panics with "Using f16 values requires + // the naga::valid::Capabilities::FLOAT16 flag". Without this + // guard the entire embed worker thread would die and epochs + // would be silently dropped for the rest of the session. + let f16_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| load_zuna_gpu_f16(config))); + match f16_result { + Ok(Some(s)) => { + info!("ZUNA GPU f16 encoder loaded"); + return Some(Encoder::ZunaGpuF16(Box::new(s))); + } + Ok(None) => warn!("GPU f16 unavailable, trying GPU f32"), + Err(_) => warn!( + "ZUNA GPU f16 panicked — adapter likely lacks SHADER_F16 \ + (naga FLOAT16 capability); falling back to GPU f32" + ), } - warn!("GPU f16 unavailable, trying GPU f32"); } #[cfg(feature = "embed-zuna-gpu")] if try_gpu { diff --git a/crates/skill-daemon/src/routes/settings_exg.rs b/crates/skill-daemon/src/routes/settings_exg.rs index 9022fe15..8e0ea6b2 100644 --- a/crates/skill-daemon/src/routes/settings_exg.rs +++ b/crates/skill-daemon/src/routes/settings_exg.rs @@ -305,7 +305,7 @@ pub(crate) fn run_batch_reembed_with_cancel( let _ = conn.execute_batch("BEGIN"); for (row_id, ts_ms) in chunk { - let ts_secs = (*ts_ms as f64) / 1000.0; + let ts_secs = skill_data::util::epoch_ts_to_unix(*ts_ms) as f64; let (samples, seg_ch_names) = extract_epoch_samples(&raw_data, ts_secs, epoch_samples); if samples.is_empty() { diff --git a/crates/skill-data/src/lib.rs b/crates/skill-data/src/lib.rs index bddf02af..8fb37226 100644 --- a/crates/skill-data/src/lib.rs +++ b/crates/skill-data/src/lib.rs @@ -39,3 +39,6 @@ pub mod util; pub mod validation_store; pub use error::{SessionError, StoreError}; +// Timestamp utilities re-exported for convenience — prefer these over +// hand-rolling `ts * 1000` arithmetic at call sites. +pub use util::{epoch_ts_to_unix, unix_to_ts, yyyymmddhhmmss_utc, DualTimestampRange}; diff --git a/crates/skill-history/src/lib.rs b/crates/skill-history/src/lib.rs index c04f3a44..f131a34e 100644 --- a/crates/skill-history/src/lib.rs +++ b/crates/skill-history/src/lib.rs @@ -911,7 +911,7 @@ pub fn list_embedding_sessions(skill_dir: &Path) -> Vec { day_names.push(day_name); if let Ok(rows) = rows { for row in rows.filter_map(std::result::Result::ok) { - all_ts.push(((row / 1000) as u64, day_idx)); + all_ts.push((skill_data::util::epoch_ts_to_unix(row), day_idx)); } } } diff --git a/crates/skill-router/src/lib.rs b/crates/skill-router/src/lib.rs index 70ff8eae..f802f753 100644 --- a/crates/skill-router/src/lib.rs +++ b/crates/skill-router/src/lib.rs @@ -109,9 +109,13 @@ pub struct RoundedScores { // ── Embedding / label loaders ───────────────────────────────────────────────── /// Load all embedding vectors from daily SQLite DBs in [start, end] UTC range. +/// +/// Uses [`skill_data::util::DualTimestampRange`] to match all three timestamp +/// formats that may be stored in the `embeddings` table (Unix ms, 14-digit +/// `YYYYMMDDHHmmss`, or 17-digit `YYYYMMDDHHmmss × 1000`). pub fn load_embeddings_range(skill_dir: &Path, start_utc: u64, end_utc: u64) -> Vec<(u64, Vec)> { - let ts_start = (start_utc as i64) * 1000; - let ts_end = (end_utc as i64) * 1000; + let r = skill_data::util::DualTimestampRange::from_unix_secs(start_utc, end_utc); + let ts_where = skill_data::util::DualTimestampRange::WHERE_CLAUSE; let mut out: Vec<(u64, Vec)> = Vec::new(); let Ok(entries) = std::fs::read_dir(skill_dir) else { @@ -130,19 +134,29 @@ pub fn load_embeddings_range(skill_dir: &Path, start_utc: u64, end_utc: u64) -> continue; }; let _ = conn.execute_batch("PRAGMA busy_timeout=2000;"); - let Ok(mut stmt) = conn.prepare( + let Ok(mut stmt) = conn.prepare(&format!( "SELECT timestamp, eeg_embedding FROM embeddings - WHERE timestamp >= ?1 AND timestamp <= ?2 ORDER BY timestamp", - ) else { + WHERE ({ts_where}) ORDER BY timestamp" + )) else { continue; }; - let rows = stmt.query_map(rusqlite::params![ts_start, ts_end], |row| { - let ts: i64 = row.get(0)?; - let blob: Vec = row.get(1)?; - let emb: Vec = skill_data::util::blob_to_f32(&blob); - Ok(((ts / 1000) as u64, emb)) - }); + let rows = stmt.query_map( + rusqlite::params![ + r.unix_ms_start, + r.unix_ms_end, + r.dt14_start, + r.dt14_end, + r.dt17_start, + r.dt17_end + ], + |row| { + let ts: i64 = row.get(0)?; + let blob: Vec = row.get(1)?; + let emb: Vec = skill_data::util::blob_to_f32(&blob); + Ok((skill_data::util::epoch_ts_to_unix(ts), emb)) + }, + ); if let Ok(rows) = rows { for r in rows.flatten() { out.push(r); From 6ded88eb64b7ed90cbfc92d0bded6c1946aed03e Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 23:33:46 -0400 Subject: [PATCH 25/98] log_enabled_by_default --- changes/unreleased/auto-git-log.md | 3 +++ crates/skill-tts/src/log.rs | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 changes/unreleased/auto-git-log.md diff --git a/changes/unreleased/auto-git-log.md b/changes/unreleased/auto-git-log.md new file mode 100644 index 00000000..8fa5c493 --- /dev/null +++ b/changes/unreleased/auto-git-log.md @@ -0,0 +1,3 @@ +### Features + +- 1. GPU f16 SHADER_F16 panic → `catch_unwind` in worker.rs diff --git a/crates/skill-tts/src/log.rs b/crates/skill-tts/src/log.rs index f8f541d9..4796d12e 100644 --- a/crates/skill-tts/src/log.rs +++ b/crates/skill-tts/src/log.rs @@ -80,15 +80,20 @@ pub fn write_log(tag: &str, msg: &str) { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + // Serialize all tests that read or write the global ENABLED flag. + static ENABLED_LOCK: Mutex<()> = Mutex::new(()); #[test] fn log_enabled_by_default() { - // Note: other tests may have toggled this, so we just check the function works - let _ = log_enabled(); + let _g = ENABLED_LOCK.lock().unwrap(); + assert!(log_enabled()); } #[test] fn set_enabled_toggles() { + let _g = ENABLED_LOCK.lock().unwrap(); set_log_enabled(false); assert!(!log_enabled()); set_log_enabled(true); @@ -97,12 +102,14 @@ mod tests { #[test] fn write_log_does_not_panic_without_callback() { + let _g = ENABLED_LOCK.lock().unwrap(); set_log_enabled(true); write_log("test", "hello from test"); } #[test] fn write_log_noop_when_disabled() { + let _g = ENABLED_LOCK.lock().unwrap(); set_log_enabled(false); write_log("test", "should not appear"); set_log_enabled(true); From eb56309b75f329a62bdae9114e0bfb2636f0caed Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Fri, 1 May 2026 23:34:55 -0400 Subject: [PATCH 26/98] 0.0.130-rc.11 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- .../auto-git-log.md => releases/0.0.130-rc.11.md} | 2 ++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 12 insertions(+), 4 deletions(-) rename changes/{unreleased/auto-git-log.md => releases/0.0.130-rc.11.md} (68%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88111bf4..e7b079d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5403,6 +5403,12 @@ The heatmap merges EEG data points with the closest timeline events to show whic - fix windows ci +## [0.0.130-rc.11] — 2026-05-02 + +### Features + +- 1. GPU f16 SHADER_F16 panic → `catch_unwind` in worker.rs + ## [0.0.130-rc.2] — 2026-04-29 ### Features diff --git a/Cargo.lock b/Cargo.lock index bf6ed607..25d82e37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11951,7 +11951,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.10" +version = "0.0.130-rc.11" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/changes/unreleased/auto-git-log.md b/changes/releases/0.0.130-rc.11.md similarity index 68% rename from changes/unreleased/auto-git-log.md rename to changes/releases/0.0.130-rc.11.md index 8fa5c493..48183a52 100644 --- a/changes/unreleased/auto-git-log.md +++ b/changes/releases/0.0.130-rc.11.md @@ -1,3 +1,5 @@ +## [0.0.130-rc.11] — 2026-05-02 + ### Features - 1. GPU f16 SHADER_F16 panic → `catch_unwind` in worker.rs diff --git a/package.json b/package.json index c44af60a..3c463152 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.10", + "version": "0.0.130-rc.11", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e1ad47e3..a8ad5b15 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.10" +version = "0.0.130-rc.11" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b5a33954..7fbb7664 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.10", + "version": "0.0.130-rc.11", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From 01c45f9bc32c19627ecacbe4397c170e1fd4e9a5 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Sun, 3 May 2026 00:11:42 -0400 Subject: [PATCH 27/98] auto-update + update RC settings --- src-tauri/src/auto_update.rs | 55 +++++++++++++++++ src-tauri/src/lib.rs | 4 ++ src/lib/i18n/de/ui.ts | 6 ++ src/lib/i18n/en/ui.ts | 5 ++ src/lib/i18n/es/ui.ts | 6 ++ src/lib/i18n/fr/ui.ts | 6 ++ src/lib/i18n/he/ui.ts | 6 ++ src/lib/i18n/ja/ui.ts | 6 ++ src/lib/i18n/keys.ts | 6 ++ src/lib/i18n/ko/ui.ts | 6 ++ src/lib/i18n/uk/ui.ts | 6 ++ src/lib/i18n/zh/ui.ts | 5 ++ src/lib/settings/UpdatesTab.svelte | 97 +++++++++++++++++++++++++++++- 13 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/auto_update.rs diff --git a/src-tauri/src/auto_update.rs b/src-tauri/src/auto_update.rs new file mode 100644 index 00000000..b11b71a1 --- /dev/null +++ b/src-tauri/src/auto_update.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +//! Auto-update opt-out preference. +//! +//! When enabled (the default), the frontend automatically downloads and +//! installs an update as soon as the background poller emits +//! `update-available`. When disabled, the same event surfaces a notice in +//! the Updates tab and the user must click "Install" to proceed. +//! +//! Storage mirrors `update_channel.rs`: a single ASCII line in +//! `/auto-update.txt` containing `true` or `false`. A +//! missing or unreadable file is treated as `true` so first-run users get +//! today's behavior. + +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +const PREF_FILE: &str = "auto-update.txt"; + +fn pref_path(app: &AppHandle) -> Option { + app.path() + .app_local_data_dir() + .ok() + .map(|d| d.join(PREF_FILE)) +} + +pub fn read_auto_update_enabled(app: &AppHandle) -> bool { + let Some(path) = pref_path(app) else { + return true; + }; + match std::fs::read_to_string(&path) { + Ok(s) => match s.trim().to_ascii_lowercase().as_str() { + "false" => false, + "true" => true, + _ => true, + }, + Err(_) => true, + } +} + +#[tauri::command] +pub fn get_auto_update_enabled(app: AppHandle) -> bool { + read_auto_update_enabled(&app) +} + +#[tauri::command] +pub fn set_auto_update_enabled(app: AppHandle, enabled: bool) -> Result<(), String> { + let path = pref_path(&app).ok_or_else(|| "app_local_data_dir unavailable".to_string())?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&path, if enabled { "true" } else { "false" }).map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9616716b..695373e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -79,6 +79,7 @@ mod tray; mod about; mod active_window; +mod auto_update; mod shortcut_cmds; mod update_channel; @@ -108,6 +109,7 @@ use std::sync::{Arc, Mutex}; use tauri::Manager; use about::{get_about_info, open_about_window}; +use auto_update::{get_auto_update_enabled, set_auto_update_enabled}; use daemon_cmds::{ cancel_session, cancel_weights_download, daemon_install_service, daemon_uninstall_service, estimate_reembed, force_restart_daemon, get_daemon_bootstrap, get_daemon_service_status, @@ -311,6 +313,8 @@ pub fn run() { set_update_channel, channel_check_for_update, channel_download_and_install, + get_auto_update_enabled, + set_auto_update_enabled, pick_ref_wav_file, get_recent_active_windows, get_recent_input_activity, diff --git a/src/lib/i18n/de/ui.ts b/src/lib/i18n/de/ui.ts index 3850e66c..6d8d5d4b 100644 --- a/src/lib/i18n/de/ui.ts +++ b/src/lib/i18n/de/ui.ts @@ -215,6 +215,12 @@ const ui: Record = { "Automatische Updateprüfung ist deaktiviert. Nutze den Button oben zur manuellen Prüfung.", "updates.autostart": "Bei Anmeldung starten", "updates.autostartDesc": "Startet automatisch, wenn du dich an deinem Computer anmeldest.", + "updates.autoUpdate": "Updates automatisch installieren", + "updates.autoUpdateDesc": + "Neue Versionen im Hintergrund herunterladen und beim nächsten Neustart installieren. Deaktivieren, um den Zeitpunkt selbst zu wählen.", + "updates.autoUpdateOffNotice": + "Automatische Installation ist aus — auf „Installieren“ klicken, um herunterzuladen und zu aktualisieren.", + "updates.installNow": "Installieren", "updates.autoCheckDesc": "Nach Updates prüfen, sobald die App startet, einmal pro Tag.", "updates.footer": "Updates werden automatisch heruntergeladen. Starten Sie neu, wenn Sie bereit sind.", diff --git a/src/lib/i18n/en/ui.ts b/src/lib/i18n/en/ui.ts index b440bc22..8e51b9fc 100644 --- a/src/lib/i18n/en/ui.ts +++ b/src/lib/i18n/en/ui.ts @@ -119,6 +119,11 @@ const ui: Record = { "updates.intervalOffWarning": "Automatic update checks are disabled. Use the button above to check manually.", "updates.autostart": "Launch at Login", "updates.autostartDesc": "Start automatically when you log in to your computer.", + "updates.autoUpdate": "Install updates automatically", + "updates.autoUpdateDesc": + "Download new versions in the background and install them on the next restart. Turn off to choose when to install.", + "updates.autoUpdateOffNotice": "Automatic install is off — click Install to download and update.", + "updates.installNow": "Install", "updates.receivePrereleases": "Receive pre-releases", "updates.receivePrereleasesDesc": "Opt into release candidates ahead of stable releases. Both manual and background update checks honor this setting live.", diff --git a/src/lib/i18n/es/ui.ts b/src/lib/i18n/es/ui.ts index 276347af..5230ca05 100644 --- a/src/lib/i18n/es/ui.ts +++ b/src/lib/i18n/es/ui.ts @@ -135,6 +135,12 @@ const ui: Record = { "Las comprobaciones de actualizaciones automáticas están deshabilitadas. Utilice el botón de arriba para comprobarlo manualmente.", "updates.autostart": "Iniciar sesión", "updates.autostartDesc": "Se inicia automáticamente cuando inicia sesión en su computadora.", + "updates.autoUpdate": "Instalar actualizaciones automáticamente", + "updates.autoUpdateDesc": + "Descarga las nuevas versiones en segundo plano e instálalas al reiniciar. Desactiva esta opción para elegir cuándo instalar.", + "updates.autoUpdateOffNotice": + "La instalación automática está desactivada — haz clic en Instalar para descargar y actualizar.", + "updates.installNow": "Instalar", "updates.footer": "Las actualizaciones se descargan automáticamente. Reinicie cuando esté listo para presentar la solicitud.", diff --git a/src/lib/i18n/fr/ui.ts b/src/lib/i18n/fr/ui.ts index cf836148..9fb2e825 100644 --- a/src/lib/i18n/fr/ui.ts +++ b/src/lib/i18n/fr/ui.ts @@ -216,6 +216,12 @@ const ui: Record = { "Les vérifications automatiques sont désactivées. Utilisez le bouton ci-dessus pour vérifier manuellement.", "updates.autostart": "Lancer à la connexion", "updates.autostartDesc": "Démarre automatiquement quand vous ouvrez une session.", + "updates.autoUpdate": "Installer les mises à jour automatiquement", + "updates.autoUpdateDesc": + "Télécharger les nouvelles versions en arrière-plan et les installer au prochain redémarrage. Désactivez pour choisir quand installer.", + "updates.autoUpdateOffNotice": + "L'installation automatique est désactivée — cliquez sur Installer pour télécharger et mettre à jour.", + "updates.installNow": "Installer", "updates.autoCheckDesc": "Vérifier les mises à jour une fois par jour au démarrage de l'application.", "updates.footer": "Les mises à jour sont téléchargées automatiquement. Redémarrez quand vous êtes prêt.", diff --git a/src/lib/i18n/he/ui.ts b/src/lib/i18n/he/ui.ts index d7cf4789..8e9353f5 100644 --- a/src/lib/i18n/he/ui.ts +++ b/src/lib/i18n/he/ui.ts @@ -220,6 +220,12 @@ const ui: Record = { "updates.intervalOffWarning": "בדיקות אוטומטיות מושבתות. השתמש בכפתור למעלה לבדיקה ידנית.", "updates.autostart": "הפעלה בכניסה למערכת", "updates.autostartDesc": "מתחיל אוטומטית כשנכנסים למחשב.", + "updates.autoUpdate": "התקן עדכונים אוטומטית", + "updates.autoUpdateDesc": + "הורד גרסאות חדשות ברקע והתקן בהפעלה הבאה. כבה כדי לבחור מתי להתקין.", + "updates.autoUpdateOffNotice": + "התקנה אוטומטית כבויה — לחץ על התקן כדי להוריד ולעדכן.", + "updates.installNow": "התקן", "updates.autoCheckDesc": "בדוק עדכונים פעם ביום כאשר האפליקציה מתחילה.", "updates.footer": "עדכונים מורדים אוטומטית. הפעל מחדש כשנוח לך.", diff --git a/src/lib/i18n/ja/ui.ts b/src/lib/i18n/ja/ui.ts index 0c887005..5b403d5c 100644 --- a/src/lib/i18n/ja/ui.ts +++ b/src/lib/i18n/ja/ui.ts @@ -116,6 +116,12 @@ const ui: Record = { "updates.intervalOffWarning": "自動アップデート確認が無効です。上のボタンを使用して手動で確認してください。", "updates.autostart": "ログイン時に起動", "updates.autostartDesc": "コンピューターにログインしたときに自動的に起動します。", + "updates.autoUpdate": "アップデートを自動的にインストール", + "updates.autoUpdateDesc": + "新しいバージョンをバックグラウンドでダウンロードし、次回の再起動時にインストールします。タイミングを自分で選ぶには無効にしてください。", + "updates.autoUpdateOffNotice": + "自動インストールはオフです — 「インストール」をクリックしてダウンロードと更新を行ってください。", + "updates.installNow": "インストール", "updates.footer": "アップデートは自動的にダウンロードされます。準備ができたら再起動して適用してください。", "whatsNew.title": "新着情報", diff --git a/src/lib/i18n/keys.ts b/src/lib/i18n/keys.ts index 89e36b1f..4157f03b 100644 --- a/src/lib/i18n/keys.ts +++ b/src/lib/i18n/keys.ts @@ -2727,7 +2727,10 @@ export type TranslationKey = | "umapSettings.timeoutDesc" | "updates.autoCheck" | "updates.autoCheckDesc" + | "updates.autoUpdate" + | "updates.autoUpdateDesc" | "updates.autoUpdateFailedOnline" + | "updates.autoUpdateOffNotice" | "updates.autostart" | "updates.autostartDesc" | "updates.available" @@ -2740,6 +2743,7 @@ export type TranslationKey = | "updates.downloadNow" | "updates.downloading" | "updates.footer" + | "updates.installNow" | "updates.installed" | "updates.interval15m" | "updates.interval1h" @@ -2751,6 +2755,8 @@ export type TranslationKey = | "updates.lastChecked" | "updates.openDownloadPageFailed" | "updates.readyToRestart" + | "updates.receivePrereleases" + | "updates.receivePrereleasesDesc" | "updates.restartNow" | "updates.restartToApply" | "updates.restartWhenReady" diff --git a/src/lib/i18n/ko/ui.ts b/src/lib/i18n/ko/ui.ts index efa9c981..bd8ebb88 100644 --- a/src/lib/i18n/ko/ui.ts +++ b/src/lib/i18n/ko/ui.ts @@ -112,6 +112,12 @@ const ui: Record = { "updates.intervalOffWarning": "자동 업데이트 확인이 비활성화되었습니다. 위의 버튼으로 수동 확인하세요.", "updates.autostart": "로그인 시 시작", "updates.autostartDesc": "컴퓨터에 로그인할 때 자동으로 시작합니다.", + "updates.autoUpdate": "업데이트 자동 설치", + "updates.autoUpdateDesc": + "백그라운드에서 새 버전을 다운로드하고 다음 재시작 시 설치합니다. 설치 시점을 직접 선택하려면 끄세요.", + "updates.autoUpdateOffNotice": + "자동 설치가 꺼져 있습니다 — 다운로드 및 업데이트하려면 설치를 클릭하세요.", + "updates.installNow": "설치", "updates.footer": "업데이트는 자동으로 다운로드됩니다. 적용 준비가 되면 재시작하세요.", "whatsNew.title": "새로운 기능", diff --git a/src/lib/i18n/uk/ui.ts b/src/lib/i18n/uk/ui.ts index 0aff8697..e512a08f 100644 --- a/src/lib/i18n/uk/ui.ts +++ b/src/lib/i18n/uk/ui.ts @@ -209,6 +209,12 @@ const ui: Record = { "updates.intervalOffWarning": "Автоматичну перевірку вимкнено. Скористайтесь кнопкою вище для перевірки вручну.", "updates.autostart": "Запуск під час входу", "updates.autostartDesc": "Запускається автоматично при вході в систему.", + "updates.autoUpdate": "Встановлювати оновлення автоматично", + "updates.autoUpdateDesc": + "Завантажувати нові версії у фоні та встановлювати під час наступного перезапуску. Вимкніть, щоб обирати момент встановлення вручну.", + "updates.autoUpdateOffNotice": + "Автоматичне встановлення вимкнено — натисніть «Встановити», щоб завантажити та оновити.", + "updates.installNow": "Встановити", "updates.autoCheckDesc": "Перевіряти оновлення раз на день під час запуску застосунку.", "updates.footer": "Оновлення завантажуються автоматично. Перезапустіть, коли будете готові.", diff --git a/src/lib/i18n/zh/ui.ts b/src/lib/i18n/zh/ui.ts index 466f2020..c936d354 100644 --- a/src/lib/i18n/zh/ui.ts +++ b/src/lib/i18n/zh/ui.ts @@ -114,6 +114,11 @@ const ui: Record = { "updates.intervalOffWarning": "已禁用自动更新检查。请使用上方按钮手动检查。", "updates.autostart": "登录时启动", "updates.autostartDesc": "登录计算机时自动启动。", + "updates.autoUpdate": "自动安装更新", + "updates.autoUpdateDesc": + "在后台下载新版本,并在下次重启时安装。关闭后可自行选择安装时机。", + "updates.autoUpdateOffNotice": "自动安装已关闭 — 点击“安装”以下载并更新。", + "updates.installNow": "安装", "updates.footer": "更新会自动下载。准备好后重启即可应用。", "whatsNew.title": "新功能", diff --git a/src/lib/settings/UpdatesTab.svelte b/src/lib/settings/UpdatesTab.svelte index ab5e07e8..7047860e 100644 --- a/src/lib/settings/UpdatesTab.svelte +++ b/src/lib/settings/UpdatesTab.svelte @@ -53,6 +53,13 @@ let updateChannelRc = $state(false); let channelSaving = $state(false); let channelError = $state(""); +// Auto-update (backend-persisted; default true). When false, the +// `update-available` event surfaces a notice + manual Install button +// instead of immediately downloading and installing. +let autoUpdateEnabled = $state(true); +let autoUpdateSaving = $state(false); +let autoUpdateError = $state(""); + // ── Interval options ────────────────────────────────────────────────────── const INTERVAL_OPTIONS: [number, string][] = [ [900, "updates.interval15m"], @@ -261,6 +268,21 @@ async function toggleChannel() { } } +// ── Auto-update opt-out ─────────────────────────────────────────────────── +async function toggleAutoUpdate() { + autoUpdateError = ""; + autoUpdateSaving = true; + const next = !autoUpdateEnabled; + try { + await invoke("set_auto_update_enabled", { enabled: next }); + autoUpdateEnabled = next; + } catch (e) { + autoUpdateError = String(e); + } finally { + autoUpdateSaving = false; + } +} + // ── Update-check interval ───────────────────────────────────────────────── async function setCheckInterval(secs: number) { intervalSaving = true; @@ -279,20 +301,28 @@ onMount(async () => { loadLastChecked(); appVersion = await invoke("get_app_version"); - const [autoEnabled, intervalSecs, savedChannel] = await Promise.all([ + const [autoEnabled, intervalSecs, savedChannel, autoUpdate] = await Promise.all([ invoke("get_autostart_enabled").catch(() => false), invoke("get_update_check_interval").catch(() => 3600), invoke("get_update_channel").catch(() => "stable"), + invoke("get_auto_update_enabled").catch(() => true), ]); autostartEnabled = autoEnabled; checkIntervalSecs = intervalSecs; updateChannelRc = savedChannel === "rc"; + autoUpdateEnabled = autoUpdate; unlisteners.push( // Background Rust task found an update — kick off download automatically. await listen<{ version: string; date?: string; body?: string }>("update-available", (ev) => { if (phase === "checking" || phase === "downloading" || phase === "ready") return; saveLastChecked(); + if (!autoUpdateEnabled) { + // Surface the notice; user installs manually via the button. + available = ev.payload; + phase = "idle"; + return; + } // Pass the event payload as a hint so checkAndDownload() keeps the // version visible in the UI while it fetches a fresh Update object, // and surfaces an error if check() returns null (CDN race) instead @@ -423,6 +453,23 @@ onDestroy(() => { {t("updates.downloadFailed")} + {:else if phase === "idle" && available && !autoUpdateEnabled} + +
+ ⬆ +
+
+ + v{available.version} {t("updates.available")} + + + {t("updates.autoUpdateOffNotice")} + +
+ {:else}
@@ -435,6 +482,7 @@ onDestroy(() => { {/if} + {#if !(phase === "idle" && available && !autoUpdateEnabled)} + {/if}
@@ -519,6 +568,52 @@ onDestroy(() => { + + + + + + {#if autoUpdateError} +
+ {autoUpdateError} +
+ {/if} +
+
+ From e7288ab0ece708aeb564ca7f1974cf580809c311 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Sun, 3 May 2026 00:28:17 -0400 Subject: [PATCH 28/98] =?UTF-8?q?1.=20Settings=20tab=20font-size=20lint=20?= =?UTF-8?q?rule=20(scripts/check-settings-font-sizes.js)=20-=20Detects=20b?= =?UTF-8?q?are=20Tailwind=20text=20sizes=20(text-xs,=20text-2xl,=20text-[1?= =?UTF-8?q?0px],=20=E2=80=A6)=20in=20src/lib/settings/*.svelte;=20only=20t?= =?UTF-8?q?ext-ui-{xs,sm,base,md,lg,xl}=20is=20allowed.=20-=20Snapshot-bas?= =?UTF-8?q?eline=20approach=20(scripts/check-settings-font-sizes.baseline.?= =?UTF-8?q?json)=20=E2=80=94=20records=20the=20existing=20287=20violations?= =?UTF-8?q?=20across=2029=20tabs=20and=20fails=20CI=20if=20any=20file's=20?= =?UTF-8?q?count=20grows=20or=20a=20clean=20file=20gains=20a=20violation.?= =?UTF-8?q?=20New=20files=20must=20start=20at=20zero.=20-=20Reports=20drop?= =?UTF-8?q?s=20too,=20and=20prints=20the=20--update=20command=20to=20refre?= =?UTF-8?q?sh=20the=20baseline=20after=20a=20real=20cleanup.=20-=20Verifie?= =?UTF-8?q?d:=20simulated=20regression=20in=20UpdatesTab.svelte=20(5=20?= =?UTF-8?q?=E2=86=92=207)=20was=20caught=20with=20exit=20code=201;=20rever?= =?UTF-8?q?ting=20brought=20it=20back=20to=20=E2=9C=85.=20-=20Wired=20into?= =?UTF-8?q?=20npm=20run=20check.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baseline doubles as a punch list — top offenders are ActivityTab (95), ValidationTab (36), and TerminalSessionsCard (27). 2. Tray hint when auto-update is OFF and an update is detected - New AppState.update_available_pending: Option (state.rs). - background.rs poller writes the pending version + refreshes the tray when auto_update_enabled is false. - tray.rs::build_menu shows ⬆ Update available: v{version} near the top; the entry is dropped automatically when staged or when auto-update is re-enabled. - Click → opens the Updates settings tab (tray_setup.rs). - Cleared in set_update_ready(true) (after install) and in set_auto_update_enabled(true) (toggle back on); both refresh the tray. --- package.json | 3 +- .../check-settings-font-sizes.baseline.json | 31 +++++ scripts/check-settings-font-sizes.js | 110 ++++++++++++++++++ src-tauri/src/auto_update.rs | 14 +++ src-tauri/src/background.rs | 11 ++ src-tauri/src/state.rs | 7 ++ src-tauri/src/tray.rs | 30 ++++- src-tauri/src/tray_setup.rs | 2 +- src-tauri/src/window_cmds.rs | 6 + 9 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 scripts/check-settings-font-sizes.baseline.json create mode 100644 scripts/check-settings-font-sizes.js diff --git a/package.json b/package.json index 3c463152..78286a30 100644 --- a/package.json +++ b/package.json @@ -36,13 +36,14 @@ "preview": "vite preview", "check:markdown-renderer": "node scripts/check-markdown-renderer.js", "check:daemon-invokes": "node scripts/check-daemon-invokes.js", + "check:settings-fonts": "node scripts/check-settings-font-sizes.js", "audit:daemon-routes": "node scripts/audit-daemon-routes.js", "verify:tauri:frontend": "node scripts/verify-tauri-frontend-structure.js", "check:i18n": "npx tsx scripts/audit-i18n.ts --check", "health": "node scripts/health.mjs", "check:i18n:locales": "node scripts/check-critical-i18n-locales.js", "check:i18n:critical": "npm run -s check:i18n:locales", - "check": "npm run -s check:markdown-renderer && npm run -s check:daemon-invokes && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check": "npm run -s check:markdown-renderer && npm run -s check:daemon-invokes && npm run -s check:settings-fonts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "npm run -s dev:guard && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --ignore src-tauri --watch", "tauri": "node scripts/tauri-build.js", "tauri:flamegraph": "node scripts/tauri-flamegraph.js", diff --git a/scripts/check-settings-font-sizes.baseline.json b/scripts/check-settings-font-sizes.baseline.json new file mode 100644 index 00000000..6cc0ccb5 --- /dev/null +++ b/scripts/check-settings-font-sizes.baseline.json @@ -0,0 +1,31 @@ +{ + "ActivityTab.svelte": 95, + "AppearanceTab.svelte": 3, + "CalibrationTab.svelte": 1, + "ClientsTab.svelte": 16, + "DevicesTab.svelte": 15, + "EegModelTab.svelte": 1, + "EmbeddingsTab.svelte": 0, + "ExgTab.svelte": 2, + "ExtensionsTab.svelte": 12, + "GoalsTab.svelte": 11, + "HooksTab.svelte": 18, + "LlmTab.svelte": 0, + "LslTab.svelte": 3, + "PermissionsTab.svelte": 7, + "PvtPanel.svelte": 6, + "ScreenshotsTab.svelte": 0, + "SettingsTab.svelte": 1, + "ShortcutsTab.svelte": 0, + "SleepTab.svelte": 3, + "TerminalSessionsCard.svelte": 27, + "TerminalTab.svelte": 9, + "TlxForm.svelte": 7, + "TokensTab.svelte": 4, + "ToolsTab.svelte": 0, + "TtsTab.svelte": 2, + "UmapTab.svelte": 0, + "UpdatesTab.svelte": 5, + "ValidationTab.svelte": 36, + "VirtualEegTab.svelte": 0 +} diff --git a/scripts/check-settings-font-sizes.js b/scripts/check-settings-font-sizes.js new file mode 100644 index 00000000..dad567bd --- /dev/null +++ b/scripts/check-settings-font-sizes.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +// +// check-settings-font-sizes.js — guard against font-size drift in +// src/lib/settings/*.svelte. +// +// Settings tabs should size text via the `text-ui-{xs,sm,base,md,lg,xl}` scale +// only. Bare Tailwind sizes (`text-xs`, `text-base`, `text-lg`, `text-2xl`, +// `text-[10px]`, …) cause visual inconsistency between tabs and were the +// motivation for introducing the `text-ui-*` system. +// +// We don't fix the existing 200+ pre-existing violations — that's a separate +// cleanup pass. Instead this script snapshots the current per-file violation +// counts in `check-settings-font-sizes.baseline.json` and fails if any file's +// count grows or a previously-clean file gains a violation. New files must +// start at zero. +// +// Refresh the baseline after an intentional cleanup with: +// node scripts/check-settings-font-sizes.js --update +// +// Allowed: text-ui-xs | text-ui-sm | text-ui-base | text-ui-md | text-ui-lg | text-ui-xl +// Violation: text-(xs|sm|base|lg|xl|xl|[]) + +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const SETTINGS_DIR = path.resolve("src/lib/settings"); +const BASELINE_PATH = path.resolve("scripts/check-settings-font-sizes.baseline.json"); + +// `(?!ui-)` skips `text-ui-*`. Trailing `\b` works for word-char endings; +// the alternation includes `\[…\]` for arbitrary values. +const VIOLATION_RE = /text-(?!ui-)((?:\d?xl|xs|sm|base|lg|xl)\b|\[[^\]]+\])/g; + +function listSettingsTabs() { + return readdirSync(SETTINGS_DIR) + .filter((f) => f.endsWith(".svelte")) + .sort(); +} + +function countViolations(filePath) { + const src = readFileSync(filePath, "utf8"); + return (src.match(VIOLATION_RE) ?? []).length; +} + +function currentCounts() { + const out = {}; + for (const file of listSettingsTabs()) { + out[file] = countViolations(path.join(SETTINGS_DIR, file)); + } + return out; +} + +function loadBaseline() { + try { + return JSON.parse(readFileSync(BASELINE_PATH, "utf8")); + } catch { + return null; + } +} + +const update = process.argv.includes("--update"); +const counts = currentCounts(); + +if (update) { + writeFileSync(BASELINE_PATH, `${JSON.stringify(counts, null, 2)}\n`); + console.log(`✅ baseline written: ${BASELINE_PATH}`); + process.exit(0); +} + +const baseline = loadBaseline(); +if (!baseline) { + writeFileSync(BASELINE_PATH, `${JSON.stringify(counts, null, 2)}\n`); + console.log(`📌 baseline initialised: ${BASELINE_PATH}`); + process.exit(0); +} + +const regressions = []; +for (const [file, count] of Object.entries(counts)) { + const prev = baseline[file]; + if (prev === undefined && count > 0) { + regressions.push(` ✗ ${file}: new file with ${count} non-ui- text size(s)`); + } else if (prev !== undefined && count > prev) { + regressions.push(` ✗ ${file}: ${prev} → ${count} non-ui- text size(s)`); + } +} + +if (regressions.length > 0) { + console.error("❌ settings tab font-size regressions:"); + console.error(regressions.join("\n")); + console.error( + "\nUse the `text-ui-{xs,sm,base,md,lg,xl}` scale instead of bare Tailwind sizes.\n" + + "If the change is intentional (e.g. removed an outlier), refresh the baseline:\n" + + " node scripts/check-settings-font-sizes.js --update", + ); + process.exit(1); +} + +// Surface drops too — they're not failures, but worth knowing. +const drops = []; +for (const [file, prev] of Object.entries(baseline)) { + const cur = counts[file]; + if (cur === undefined) continue; + if (cur < prev) drops.push(` ✓ ${file}: ${prev} → ${cur}`); +} +if (drops.length > 0) { + console.log("ℹ︎ settings tab font-size violations decreased — refresh baseline to lock in:"); + console.log(drops.join("\n")); + console.log(" node scripts/check-settings-font-sizes.js --update"); +} + +console.log("✅ no settings tab font-size regressions"); diff --git a/src-tauri/src/auto_update.rs b/src-tauri/src/auto_update.rs index b11b71a1..16ffe1b8 100644 --- a/src-tauri/src/auto_update.rs +++ b/src-tauri/src/auto_update.rs @@ -14,8 +14,12 @@ //! today's behavior. use std::path::PathBuf; +use std::sync::Mutex; use tauri::{AppHandle, Manager}; +use crate::state::AppState; +use crate::MutexExt; + const PREF_FILE: &str = "auto-update.txt"; fn pref_path(app: &AppHandle) -> Option { @@ -51,5 +55,15 @@ pub fn set_auto_update_enabled(app: AppHandle, enabled: bool) -> Result<(), Stri std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } std::fs::write(&path, if enabled { "true" } else { "false" }).map_err(|e| e.to_string())?; + if enabled { + // Auto-update is back on — drop any "⬆ Update available" tray hint; + // the next background poll will trigger the usual auto-download. + let r = app.state::>>(); + let mut g = r.lock_or_recover(); + if g.update_available_pending.take().is_some() { + drop(g); + crate::tray::refresh_tray(&app); + } + } Ok(()) } diff --git a/src-tauri/src/background.rs b/src-tauri/src/background.rs index 924aba13..ed7d61ee 100644 --- a/src-tauri/src/background.rs +++ b/src-tauri/src/background.rs @@ -226,6 +226,17 @@ fn spawn_updater_poll(handle: &AppHandle) { Err(_) => eprintln!("[updater] check timed out after 30 s"), Ok(Ok(Some(update))) => { eprintln!("[updater] update available: {}", update.version); + // When auto-update is off, mirror the version into AppState so + // the tray menu can surface "⬆ Update available …". The + // frontend gets the same event either way and decides whether + // to auto-download. + if !crate::auto_update::read_auto_update_enabled(&app) { + let r = app.state::>>(); + let mut g = r.lock_or_recover(); + g.update_available_pending = Some(update.version.clone()); + drop(g); + crate::tray::refresh_tray(&app); + } let payload = serde_json::json!({ "version": update.version, "date": update.date, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 9d61eaed..c11df45e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -381,6 +381,12 @@ pub struct AppState { /// Set by the frontend when an update has been downloaded and is ready /// to install on next restart / relaunch. pub update_ready_to_install: bool, + /// Version string of an update detected by the background poller while + /// the auto-update toggle is OFF — surfaces in the tray menu so the + /// user notices a pending update without opening Settings. Cleared + /// when the user installs (`set_update_ready(true)`) or re-enables + /// auto-update. + pub update_available_pending: Option, // ── Device configs ──────────────────────────────────────────────────── pub openbci_config: crate::settings::OpenBciConfig, @@ -498,6 +504,7 @@ impl Default for AppState { hf_endpoint: skill_settings::default_hf_endpoint(), update_check_interval_secs: default_update_check_interval(), update_ready_to_install: false, + update_available_pending: None, openbci_config: crate::settings::OpenBciConfig::default(), location_enabled: false, inference_device: skill_settings::default_inference_device(), diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 6c067384..8164d115 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -143,6 +143,15 @@ fn structure_key(st: &DeviceStatus, app: &AppHandle) -> String { let llm_downloads = tray_download_fingerprint(app); + // Pending update version (only set when auto-update is OFF). Including it + // in the structure key forces a rebuild that adds/removes the tray hint + // when the background poller flips the state. + let pending_update = { + let r = app.app_state(); + let g = r.lock_or_recover(); + g.update_available_pending.clone().unwrap_or_default() + }; + let mut pair_parts = st .paired_devices .iter() @@ -156,7 +165,7 @@ fn structure_key(st: &DeviceStatus, app: &AppHandle) -> String { let state = st.state.as_str(); format!( - "{state}|{pairs}|{ls}|{ss}|{sets}|{cs}|{hs}|{hist}|{api}|{ts}|{ft}|{chat}|{compare}|{llm_downloads}" + "{state}|{pairs}|{ls}|{ss}|{sets}|{cs}|{hs}|{hist}|{api}|{ts}|{ft}|{chat}|{compare}|{llm_downloads}|{pending_update}" ) } @@ -274,6 +283,25 @@ pub(crate) fn build_menu(app: &AppHandle, st: &DeviceStatus) -> tauri::Result, + )?)?; + } + menu.append(&PredefinedMenuItem::separator(app)?)?; // ── Status info (always present — updated in-place by update_status_items) ── diff --git a/src-tauri/src/tray_setup.rs b/src-tauri/src/tray_setup.rs index 320ee935..060bde83 100644 --- a/src-tauri/src/tray_setup.rs +++ b/src-tauri/src/tray_setup.rs @@ -122,7 +122,7 @@ pub(crate) fn build_tray( }); } else if id == "show_logs" { crate::window_cmds::open_latest_log(); - } else if id == "check_update" { + } else if id == "check_update" || id == "update_available" { let a = app.clone(); tauri::async_runtime::spawn(async move { let _ = crate::window_cmds::open_updates_window(a).await; diff --git a/src-tauri/src/window_cmds.rs b/src-tauri/src/window_cmds.rs index bad14420..19e5732f 100644 --- a/src-tauri/src/window_cmds.rs +++ b/src-tauri/src/window_cmds.rs @@ -1255,6 +1255,12 @@ pub fn set_update_ready(app: AppHandle, ready: bool) { let r = app.state::>>(); let mut g = r.lock_or_recover(); g.update_ready_to_install = ready; + if ready { + // Once staged, the "⬆ Update available" tray hint is redundant. + g.update_available_pending = None; + drop(g); + crate::tray::refresh_tray(&app); + } } #[tauri::command] From 6cbf76a6828fef5efaf743ffd165841fa7c8d78f Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Sun, 3 May 2026 00:30:34 -0400 Subject: [PATCH 29/98] translations --- src/lib/i18n/he/ui.ts | 6 ++---- src/lib/i18n/ko/ui.ts | 3 +-- src/lib/i18n/zh/ui.ts | 3 +-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/lib/i18n/he/ui.ts b/src/lib/i18n/he/ui.ts index 8e9353f5..99ee85b3 100644 --- a/src/lib/i18n/he/ui.ts +++ b/src/lib/i18n/he/ui.ts @@ -221,10 +221,8 @@ const ui: Record = { "updates.autostart": "הפעלה בכניסה למערכת", "updates.autostartDesc": "מתחיל אוטומטית כשנכנסים למחשב.", "updates.autoUpdate": "התקן עדכונים אוטומטית", - "updates.autoUpdateDesc": - "הורד גרסאות חדשות ברקע והתקן בהפעלה הבאה. כבה כדי לבחור מתי להתקין.", - "updates.autoUpdateOffNotice": - "התקנה אוטומטית כבויה — לחץ על התקן כדי להוריד ולעדכן.", + "updates.autoUpdateDesc": "הורד גרסאות חדשות ברקע והתקן בהפעלה הבאה. כבה כדי לבחור מתי להתקין.", + "updates.autoUpdateOffNotice": "התקנה אוטומטית כבויה — לחץ על התקן כדי להוריד ולעדכן.", "updates.installNow": "התקן", "updates.autoCheckDesc": "בדוק עדכונים פעם ביום כאשר האפליקציה מתחילה.", "updates.footer": "עדכונים מורדים אוטומטית. הפעל מחדש כשנוח לך.", diff --git a/src/lib/i18n/ko/ui.ts b/src/lib/i18n/ko/ui.ts index bd8ebb88..f047be45 100644 --- a/src/lib/i18n/ko/ui.ts +++ b/src/lib/i18n/ko/ui.ts @@ -115,8 +115,7 @@ const ui: Record = { "updates.autoUpdate": "업데이트 자동 설치", "updates.autoUpdateDesc": "백그라운드에서 새 버전을 다운로드하고 다음 재시작 시 설치합니다. 설치 시점을 직접 선택하려면 끄세요.", - "updates.autoUpdateOffNotice": - "자동 설치가 꺼져 있습니다 — 다운로드 및 업데이트하려면 설치를 클릭하세요.", + "updates.autoUpdateOffNotice": "자동 설치가 꺼져 있습니다 — 다운로드 및 업데이트하려면 설치를 클릭하세요.", "updates.installNow": "설치", "updates.footer": "업데이트는 자동으로 다운로드됩니다. 적용 준비가 되면 재시작하세요.", diff --git a/src/lib/i18n/zh/ui.ts b/src/lib/i18n/zh/ui.ts index c936d354..6dc5bc93 100644 --- a/src/lib/i18n/zh/ui.ts +++ b/src/lib/i18n/zh/ui.ts @@ -115,8 +115,7 @@ const ui: Record = { "updates.autostart": "登录时启动", "updates.autostartDesc": "登录计算机时自动启动。", "updates.autoUpdate": "自动安装更新", - "updates.autoUpdateDesc": - "在后台下载新版本,并在下次重启时安装。关闭后可自行选择安装时机。", + "updates.autoUpdateDesc": "在后台下载新版本,并在下次重启时安装。关闭后可自行选择安装时机。", "updates.autoUpdateOffNotice": "自动安装已关闭 — 点击“安装”以下载并更新。", "updates.installNow": "安装", "updates.footer": "更新会自动下载。准备好后重启即可应用。", From a541f1338b9114a07020a93bbbdb2b324586e3db Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Sun, 3 May 2026 00:31:48 -0400 Subject: [PATCH 30/98] 0.0.130-rc.12 --- CHANGELOG.md | 10 ++++ Cargo.lock | 54 +++++++++---------- changes/releases/0.0.130-rc.12.md | 9 ++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- .../generated/settings-search-index.de.json | 6 +++ .../generated/settings-search-index.en.json | 6 +++ .../generated/settings-search-index.es.json | 6 +++ .../generated/settings-search-index.fr.json | 6 +++ .../generated/settings-search-index.he.json | 6 +++ .../generated/settings-search-index.ja.json | 6 +++ .../generated/settings-search-index.ko.json | 6 +++ .../generated/settings-search-index.uk.json | 6 +++ .../generated/settings-search-index.zh.json | 6 +++ .../generated/settings-search-manifest.json | 2 +- 16 files changed, 104 insertions(+), 31 deletions(-) create mode 100644 changes/releases/0.0.130-rc.12.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e7b079d9..bd6075e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5409,6 +5409,16 @@ The heatmap merges EEG data points with the closest timeline events to show whic - 1. GPU f16 SHADER_F16 panic → `catch_unwind` in worker.rs +## [0.0.130-rc.12] — 2026-05-03 + +### Features + +- translations +- 1. Settings tab font-size lint rule (scripts/check-settings-font-sizes.js) +- The baseline doubles as a punch list — top offenders are ActivityTab (95), ValidationTab (36), and TerminalSessionsCard (27). +- 2. Tray hint when auto-update is OFF and an update is detected +- auto-update + update RC settings + ## [0.0.130-rc.2] — 2026-04-29 ### Features diff --git a/Cargo.lock b/Cargo.lock index 25d82e37..79ba55b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,9 +292,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow-array" -version = "58.1.0" +version = "58.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772bd34cacdda8baec9418d80d23d0fb4d50ef0735685bd45158b83dfeb6e62d" +checksum = "841321891f247aa86c6112c80d83d89cb36e0addd020fa2425085b8eb6c3f579" dependencies = [ "ahash", "arrow-buffer", @@ -302,7 +302,7 @@ dependencies = [ "arrow-schema", "chrono", "half", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "num-complex", "num-integer", "num-traits", @@ -310,9 +310,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "58.1.0" +version = "58.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "898f4cf1e9598fdb77f356fdf2134feedfd0ee8d5a4e0a5f573e7d0aec16baa4" +checksum = "f955dfb73fae000425f49c8226d2044dab60fb7ad4af1e24f961756354d996c9" dependencies = [ "bytes", "half", @@ -322,9 +322,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "58.1.0" +version = "58.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d10beeab2b1c3bb0b53a00f7c944a178b622173a5c7bcabc3cb45d90238df4" +checksum = "db3b5846209775b6dc8056d77ff9a032b27043383dd5488abd0b663e265b9373" dependencies = [ "arrow-buffer", "arrow-schema", @@ -335,9 +335,9 @@ dependencies = [ [[package]] name = "arrow-ipc" -version = "58.1.0" +version = "58.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "609a441080e338147a84e8e6904b6da482cefb957c5cdc0f3398872f69a315d0" +checksum = "fd8907ddd8f9fbabf91ec2c85c1d81fe2874e336d2443eb36373595e28b98dd5" dependencies = [ "arrow-array", "arrow-buffer", @@ -349,15 +349,15 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "58.1.0" +version = "58.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c30a1365d7a7dc50cc847e54154e6af49e4c4b0fddc9f607b687f29212082743" +checksum = "18aa020f6bc8e5201dcd2d4b7f98c68f8a410ef37128263243e6ff2a47a67d4f" [[package]] name = "arrow-select" -version = "58.1.0" +version = "58.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78694888660a9e8ac949853db393af2a8b8fc82c19ce333132dfa2e72cc1a7fe" +checksum = "a657ab5132e9c8ca3b24eb15a823d0ced38017fe3930ff50167466b02e2d592c" dependencies = [ "ahash", "arrow-array", @@ -9419,9 +9419,9 @@ dependencies = [ [[package]] name = "parquet" -version = "58.1.0" +version = "58.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d3f9f2205199603564127932b89695f52b62322f541d0fc7179d57c2e1c9877" +checksum = "43d7efd3052f7d6ef601085559a246bc991e9a8cc77e02753737df6322ce35f1" dependencies = [ "ahash", "arrow-array", @@ -9434,7 +9434,7 @@ dependencies = [ "bytes", "chrono", "half", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "num-bigint", "num-integer", "num-traits", @@ -11726,9 +11726,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" dependencies = [ "base64 0.22.1", "chrono", @@ -11745,9 +11745,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -11899,9 +11899,9 @@ dependencies = [ [[package]] name = "signature" -version = "3.0.0-rc.10" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" [[package]] name = "simd-adler32" @@ -11951,7 +11951,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skill" -version = "0.0.130-rc.11" +version = "0.0.130-rc.12" dependencies = [ "anyhow", "base64 0.22.1", @@ -13476,9 +13476,9 @@ dependencies = [ [[package]] name = "tauri-plugin-opener" -version = "2.5.3" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", @@ -13508,9 +13508,9 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" dependencies = [ "serde", "serde_json", diff --git a/changes/releases/0.0.130-rc.12.md b/changes/releases/0.0.130-rc.12.md new file mode 100644 index 00000000..a251d880 --- /dev/null +++ b/changes/releases/0.0.130-rc.12.md @@ -0,0 +1,9 @@ +## [0.0.130-rc.12] — 2026-05-03 + +### Features + +- translations +- 1. Settings tab font-size lint rule (scripts/check-settings-font-sizes.js) +- The baseline doubles as a punch list — top offenders are ActivityTab (95), ValidationTab (36), and TerminalSessionsCard (27). +- 2. Tray hint when auto-update is OFF and an update is detected +- auto-update + update RC settings diff --git a/package.json b/package.json index 78286a30..3857723b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.11", + "version": "0.0.130-rc.12", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a8ad5b15..a021a1d3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.11" +version = "0.0.130-rc.12" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7fbb7664..898d430b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.11", + "version": "0.0.130-rc.12", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/lib/generated/settings-search-index.de.json b/src/lib/generated/settings-search-index.de.json index d8d37c27..506a0060 100644 --- a/src/lib/generated/settings-search-index.de.json +++ b/src/lib/generated/settings-search-index.de.json @@ -833,6 +833,12 @@ "label": "Bei Anmeldung starten", "desc": "Startet automatisch, wenn du dich an deinem Computer anmeldest." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "Updates automatisch installieren", + "desc": "Neue Versionen im Hintergrund herunterladen und beim nächsten Neustart installieren. Deaktivieren, um den Zeitpunkt selbst zu wählen." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.en.json b/src/lib/generated/settings-search-index.en.json index 631bd1da..a7e33d1a 100644 --- a/src/lib/generated/settings-search-index.en.json +++ b/src/lib/generated/settings-search-index.en.json @@ -833,6 +833,12 @@ "label": "Launch at Login", "desc": "Start automatically when you log in to your computer." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "Install updates automatically", + "desc": "Download new versions in the background and install them on the next restart. Turn off to choose when to install." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.es.json b/src/lib/generated/settings-search-index.es.json index 74948814..b65de8aa 100644 --- a/src/lib/generated/settings-search-index.es.json +++ b/src/lib/generated/settings-search-index.es.json @@ -833,6 +833,12 @@ "label": "Iniciar sesión", "desc": "Se inicia automáticamente cuando inicia sesión en su computadora." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "Instalar actualizaciones automáticamente", + "desc": "Descarga las nuevas versiones en segundo plano e instálalas al reiniciar. Desactiva esta opción para elegir cuándo instalar." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.fr.json b/src/lib/generated/settings-search-index.fr.json index 8c90297f..2adeae81 100644 --- a/src/lib/generated/settings-search-index.fr.json +++ b/src/lib/generated/settings-search-index.fr.json @@ -833,6 +833,12 @@ "label": "Lancer à la connexion", "desc": "Démarre automatiquement quand vous ouvrez une session." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "Installer les mises à jour automatiquement", + "desc": "Télécharger les nouvelles versions en arrière-plan et les installer au prochain redémarrage. Désactivez pour choisir quand installer." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.he.json b/src/lib/generated/settings-search-index.he.json index d0eb849b..9f2d290b 100644 --- a/src/lib/generated/settings-search-index.he.json +++ b/src/lib/generated/settings-search-index.he.json @@ -833,6 +833,12 @@ "label": "הפעלה בכניסה למערכת", "desc": "מתחיל אוטומטית כשנכנסים למחשב." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "התקן עדכונים אוטומטית", + "desc": "הורד גרסאות חדשות ברקע והתקן בהפעלה הבאה. כבה כדי לבחור מתי להתקין." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.ja.json b/src/lib/generated/settings-search-index.ja.json index f9d39625..53d08d74 100644 --- a/src/lib/generated/settings-search-index.ja.json +++ b/src/lib/generated/settings-search-index.ja.json @@ -833,6 +833,12 @@ "label": "ログイン時に起動", "desc": "コンピューターにログインしたときに自動的に起動します。" }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "アップデートを自動的にインストール", + "desc": "新しいバージョンをバックグラウンドでダウンロードし、次回の再起動時にインストールします。タイミングを自分で選ぶには無効にしてください。" + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.ko.json b/src/lib/generated/settings-search-index.ko.json index 84794b7d..670108dc 100644 --- a/src/lib/generated/settings-search-index.ko.json +++ b/src/lib/generated/settings-search-index.ko.json @@ -833,6 +833,12 @@ "label": "로그인 시 시작", "desc": "컴퓨터에 로그인할 때 자동으로 시작합니다." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "업데이트 자동 설치", + "desc": "백그라운드에서 새 버전을 다운로드하고 다음 재시작 시 설치합니다. 설치 시점을 직접 선택하려면 끄세요." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.uk.json b/src/lib/generated/settings-search-index.uk.json index 2877b856..b870f649 100644 --- a/src/lib/generated/settings-search-index.uk.json +++ b/src/lib/generated/settings-search-index.uk.json @@ -833,6 +833,12 @@ "label": "Запуск під час входу", "desc": "Запускається автоматично при вході в систему." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "Встановлювати оновлення автоматично", + "desc": "Завантажувати нові версії у фоні та встановлювати під час наступного перезапуску. Вимкніть, щоб обирати момент встановлення вручну." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.zh.json b/src/lib/generated/settings-search-index.zh.json index e1a41fb8..abd4b6e9 100644 --- a/src/lib/generated/settings-search-index.zh.json +++ b/src/lib/generated/settings-search-index.zh.json @@ -833,6 +833,12 @@ "label": "登录时启动", "desc": "登录计算机时自动启动。" }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "自动安装更新", + "desc": "在后台下载新版本,并在下次重启时安装。关闭后可自行选择安装时机。" + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-manifest.json b/src/lib/generated/settings-search-manifest.json index 9404325c..2551ac7e 100644 --- a/src/lib/generated/settings-search-manifest.json +++ b/src/lib/generated/settings-search-manifest.json @@ -10,5 +10,5 @@ "uk", "zh" ], - "entriesPerLocale": 142 + "entriesPerLocale": 143 } \ No newline at end of file From 4e52dd6f7ab9eb8f0b98e78562707b127b68b942 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 06:48:09 +0000 Subject: [PATCH 31/98] deps(npm): bump the npm-all group with 10 updates Bumps the npm-all group with 10 updates: | Package | From | To | | --- | --- | --- | | [@tauri-apps/api](https://github.com/tauri-apps/tauri) | `2.10.1` | `2.11.0` | | [@tauri-apps/plugin-opener](https://github.com/tauri-apps/plugins-workspace) | `2.5.3` | `2.5.4` | | [@threlte/core](https://github.com/threlte/threlte/tree/HEAD/packages/core) | `8.5.9` | `8.5.11` | | [@threlte/extras](https://github.com/threlte/threlte/tree/HEAD/packages/extras) | `9.14.9` | `9.15.1` | | [bits-ui](https://github.com/huntabyte/bits-ui) | `2.18.0` | `2.18.1` | | [marked](https://github.com/markedjs/marked) | `18.0.2` | `18.0.3` | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.13` | `2.4.14` | | [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) | `2.58.0` | `2.59.0` | | [@tauri-apps/cli](https://github.com/tauri-apps/tauri) | `2.10.1` | `2.11.0` | | [svelte-check](https://github.com/sveltejs/language-tools) | `4.4.6` | `4.4.7` | Updates `@tauri-apps/api` from 2.10.1 to 2.11.0 - [Release notes](https://github.com/tauri-apps/tauri/releases) - [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/api-v2.10.1...@tauri-apps/api-v2.11.0) Updates `@tauri-apps/plugin-opener` from 2.5.3 to 2.5.4 - [Release notes](https://github.com/tauri-apps/plugins-workspace/releases) - [Commits](https://github.com/tauri-apps/plugins-workspace/compare/http-v2.5.3...http-v2.5.4) Updates `@threlte/core` from 8.5.9 to 8.5.11 - [Release notes](https://github.com/threlte/threlte/releases) - [Changelog](https://github.com/threlte/threlte/blob/main/packages/core/CHANGELOG.md) - [Commits](https://github.com/threlte/threlte/commits/@threlte/core@8.5.11/packages/core) Updates `@threlte/extras` from 9.14.9 to 9.15.1 - [Release notes](https://github.com/threlte/threlte/releases) - [Changelog](https://github.com/threlte/threlte/blob/main/packages/extras/CHANGELOG.md) - [Commits](https://github.com/threlte/threlte/commits/@threlte/extras@9.15.1/packages/extras) Updates `bits-ui` from 2.18.0 to 2.18.1 - [Release notes](https://github.com/huntabyte/bits-ui/releases) - [Commits](https://github.com/huntabyte/bits-ui/compare/bits-ui@2.18.0...bits-ui@2.18.1) Updates `marked` from 18.0.2 to 18.0.3 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v18.0.2...v18.0.3) Updates `@biomejs/biome` from 2.4.13 to 2.4.14 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.14/packages/@biomejs/biome) Updates `@sveltejs/kit` from 2.58.0 to 2.59.0 - [Release notes](https://github.com/sveltejs/kit/releases) - [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md) - [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.59.0/packages/kit) Updates `@tauri-apps/cli` from 2.10.1 to 2.11.0 - [Release notes](https://github.com/tauri-apps/tauri/releases) - [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/cli-v2.10.1...@tauri-apps/cli-v2.11.0) Updates `svelte-check` from 4.4.6 to 4.4.7 - [Release notes](https://github.com/sveltejs/language-tools/releases) - [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check@4.4.6...svelte-check@4.4.7) --- updated-dependencies: - dependency-name: "@tauri-apps/api" dependency-version: 2.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-all - dependency-name: "@tauri-apps/plugin-opener" dependency-version: 2.5.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-all - dependency-name: "@threlte/core" dependency-version: 8.5.11 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-all - dependency-name: "@threlte/extras" dependency-version: 9.15.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-all - dependency-name: bits-ui dependency-version: 2.18.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-all - dependency-name: marked dependency-version: 18.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-all - dependency-name: "@biomejs/biome" dependency-version: 2.4.14 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-all - dependency-name: "@sveltejs/kit" dependency-version: 2.59.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-all - dependency-name: "@tauri-apps/cli" dependency-version: 2.11.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-all - dependency-name: svelte-check dependency-version: 4.4.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-all ... Signed-off-by: dependabot[bot] --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 892e8f3b..39f85c0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "neuroskill", - "version": "0.0.129", + "version": "0.0.130-rc.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neuroskill", - "version": "0.0.129", + "version": "0.0.130-rc.12", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { From 75c48a249d169d894fd2f42b4d7587f46724b84e Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Mon, 4 May 2026 16:46:44 -0400 Subject: [PATCH 32/98] 0.0.130-rc.13 --- CHANGELOG.md | 20 +++++ Cargo.lock | 124 ++++++++++++++++++------------ changes/releases/0.0.130-rc.13.md | 19 +++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 117 insertions(+), 52 deletions(-) create mode 100644 changes/releases/0.0.130-rc.13.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6075e8..f4c1460f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5419,6 +5419,26 @@ The heatmap merges EEG data points with the closest timeline events to show whic - 2. Tray hint when auto-update is OFF and an update is detected - auto-update + update RC settings +## [0.0.130-rc.13] — 2026-05-04 + +### Features + +- deps(npm): bump the npm-all group with 10 updates +- Bumps the npm-all group with 10 updates: +- | Package | From | To | +- Updates `@tauri-apps/api` from 2.10.1 to 2.11.0 +- Updates `@tauri-apps/plugin-opener` from 2.5.3 to 2.5.4 +- Updates `@threlte/core` from 8.5.9 to 8.5.11 +- Updates `@threlte/extras` from 9.14.9 to 9.15.1 +- Updates `bits-ui` from 2.18.0 to 2.18.1 +- Updates `marked` from 18.0.2 to 18.0.3 +- Updates `@biomejs/biome` from 2.4.13 to 2.4.14 +- Updates `@sveltejs/kit` from 2.58.0 to 2.59.0 +- Updates `@tauri-apps/cli` from 2.10.1 to 2.11.0 +- Updates `svelte-check` from 4.4.6 to 4.4.7 +- --- +- Signed-off-by: dependabot[bot] + ## [0.0.130-rc.2] — 2026-04-29 ### Features diff --git a/Cargo.lock b/Cargo.lock index 79ba55b9..74df3344 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5447,9 +5447,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -5668,7 +5668,7 @@ dependencies = [ "futures-channel", "futures-io", "futures-util", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "idna", "ipnet", @@ -5874,7 +5874,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -6927,11 +6927,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "libc", ] @@ -7070,7 +7070,7 @@ dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.7.5", ] [[package]] @@ -7165,6 +7165,16 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "line-clipping" version = "0.3.7" @@ -7216,9 +7226,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llama-cpp-4" -version = "0.2.51" +version = "0.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "848f0db0643df8e38aabe14e6a74d99cb6202b142a83cbffa1fb5bc2e427ecb4" +checksum = "091677d85cf60d8130fede951c46720527f83daa319d93f4be3af06b2d8f6c56" dependencies = [ "enumflags2", "llama-cpp-sys-4", @@ -7228,9 +7238,9 @@ dependencies = [ [[package]] name = "llama-cpp-sys-4" -version = "0.2.51" +version = "0.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00267ef600935213dbbb794409c16a450a61eb1f66ce723a5179bfaa949d0f6e" +checksum = "ee9e8dcac498fe70f6241e7e41298fecea4309b236522fbf2181c35d5d24aeaa" dependencies = [ "bindgen 0.72.1", "cc", @@ -7241,14 +7251,16 @@ dependencies = [ [[package]] name = "llmfit-core" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57796197a65dea17e886c10cd474d8be9dbf755c84d4909064961a62e5fd5f42" +checksum = "dd3f64575b8b4d385d9a9e4ce4b19c2acf94dd8cd3edb6bcb5cf134dcf4ba9b9" dependencies = [ "dirs", "http 1.4.0", + "regex", "serde", "serde_json", + "serde_yml", "sysinfo 0.38.4", "ureq 3.3.0", "which", @@ -9185,15 +9197,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags 2.11.1", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -9217,9 +9228,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", @@ -9773,7 +9784,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.39.2", + "quick-xml 0.39.3", "serde", "time", ] @@ -10076,18 +10087,18 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", "syn 2.0.117", @@ -10246,9 +10257,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.2" +version = "0.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" dependencies = [ "memchr", ] @@ -10634,9 +10645,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags 2.11.1", ] @@ -10793,7 +10804,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -11768,6 +11779,21 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -11945,13 +11971,13 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skill" -version = "0.0.130-rc.12" +version = "0.0.130-rc.13" dependencies = [ "anyhow", "base64 0.22.1", @@ -13238,9 +13264,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.0" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159" +checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" dependencies = [ "bitflags 2.11.1", "block2 0.6.2", @@ -13595,14 +13621,14 @@ dependencies = [ "percent-encoding", "raw-window-handle", "softbuffer", - "tao 0.35.0", + "tao 0.35.2", "tauri-runtime", "tauri-utils", "url", "webkit2gtk", "webview2-com", "windows 0.61.3", - "wry 0.55.0", + "wry 0.55.1", ] [[package]] @@ -13985,9 +14011,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", @@ -15410,7 +15436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml 0.39.2", + "quick-xml 0.39.3", "quote", ] @@ -16662,9 +16688,9 @@ dependencies = [ [[package]] name = "wry" -version = "0.55.0" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2 0.6.2", @@ -16973,7 +16999,7 @@ dependencies = [ "winnow 1.0.2", "zbus_macros 5.15.0", "zbus_names 4.3.2", - "zvariant 5.10.1", + "zvariant 5.11.0", ] [[package]] @@ -17000,7 +17026,7 @@ dependencies = [ "quote", "syn 2.0.117", "zbus_names 4.3.2", - "zvariant 5.10.1", + "zvariant 5.11.0", "zvariant_utils 3.3.1", ] @@ -17023,7 +17049,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.2", - "zvariant 5.10.1", + "zvariant 5.11.0", ] [[package]] @@ -17288,15 +17314,15 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.1" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.2", - "zvariant_derive 5.10.1", + "zvariant_derive 5.11.0", "zvariant_utils 3.3.1", ] @@ -17315,9 +17341,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.10.1" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", diff --git a/changes/releases/0.0.130-rc.13.md b/changes/releases/0.0.130-rc.13.md new file mode 100644 index 00000000..087d190d --- /dev/null +++ b/changes/releases/0.0.130-rc.13.md @@ -0,0 +1,19 @@ +## [0.0.130-rc.13] — 2026-05-04 + +### Features + +- deps(npm): bump the npm-all group with 10 updates +- Bumps the npm-all group with 10 updates: +- | Package | From | To | +- Updates `@tauri-apps/api` from 2.10.1 to 2.11.0 +- Updates `@tauri-apps/plugin-opener` from 2.5.3 to 2.5.4 +- Updates `@threlte/core` from 8.5.9 to 8.5.11 +- Updates `@threlte/extras` from 9.14.9 to 9.15.1 +- Updates `bits-ui` from 2.18.0 to 2.18.1 +- Updates `marked` from 18.0.2 to 18.0.3 +- Updates `@biomejs/biome` from 2.4.13 to 2.4.14 +- Updates `@sveltejs/kit` from 2.58.0 to 2.59.0 +- Updates `@tauri-apps/cli` from 2.10.1 to 2.11.0 +- Updates `svelte-check` from 4.4.6 to 4.4.7 +- --- +- Signed-off-by: dependabot[bot] diff --git a/package.json b/package.json index 3857723b..95453a87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.130-rc.12", + "version": "0.0.130-rc.13", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a021a1d3..a3e59e8d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.130-rc.12" +version = "0.0.130-rc.13" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 898d430b..f96bf3eb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.130-rc.12", + "version": "0.0.130-rc.13", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", From 2456b1be8980d646261782c8848401fa05907d9c Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Mon, 4 May 2026 18:09:34 -0400 Subject: [PATCH 33/98] fix max buffer --- crates/skill-daemon/src/embed/accumulator.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/skill-daemon/src/embed/accumulator.rs b/crates/skill-daemon/src/embed/accumulator.rs index 8fbdca8e..371f9de5 100644 --- a/crates/skill-daemon/src/embed/accumulator.rs +++ b/crates/skill-daemon/src/embed/accumulator.rs @@ -36,6 +36,7 @@ pub struct EpochAccumulator { device_channels: usize, hop_samples: usize, native_epoch_samples: usize, + max_buf_samples: usize, device_name: Option, channel_names: Vec, sample_rate: f32, @@ -62,6 +63,7 @@ impl EpochAccumulator { device_channels: device_channels.min(EEG_CHANNELS), hop_samples: hop, native_epoch_samples: native_epoch, + max_buf_samples: native_epoch * 4, device_name: None, channel_names, sample_rate, @@ -101,6 +103,20 @@ impl EpochAccumulator { self.bufs[electrode].extend(samples.iter().copied()); self.since_last[electrode] += samples.len(); + // Per-channel cap: when other electrodes stall, this channel would + // otherwise grow without bound (the drain step requires min_buf across + // all channels). Drop oldest down to one epoch's worth. + if self.bufs[electrode].len() > self.max_buf_samples { + let drop = self.bufs[electrode].len() - self.native_epoch_samples; + self.bufs[electrode].drain(..drop); + self.since_last[electrode] = self.since_last[electrode].min(self.native_epoch_samples); + info!( + channel = electrode, + dropped = drop, + "epoch buf overflow — channel imbalance, dropping oldest samples" + ); + } + let n_ch = self.device_channels; let native_epoch = self.native_epoch_samples; From 488ac4dd887395a633ecf25251855a842d036083 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Mon, 4 May 2026 19:13:38 -0400 Subject: [PATCH 34/98] 1 hour roll over in csv --- crates/skill-daemon/src/activity.rs | 10 ++ .../skill-daemon/src/routes/settings_exg.rs | 58 ++++++- crates/skill-daemon/src/session/mod.rs | 1 + crates/skill-daemon/src/session/pipeline.rs | 139 ++++++++++++++++ crates/skill-daemon/src/session/retention.rs | 150 ++++++++++++++++++ crates/skill-daemon/src/session/runner.rs | 145 +++++++++++++++++ crates/skill-data/src/session_parquet.rs | 99 ++++++++++-- crates/skill-data/src/session_writer.rs | 13 ++ crates/skill-settings/src/lib.rs | 10 ++ 9 files changed, 610 insertions(+), 15 deletions(-) create mode 100644 crates/skill-daemon/src/session/retention.rs diff --git a/crates/skill-daemon/src/activity.rs b/crates/skill-daemon/src/activity.rs index 31bb49a2..26c8aef9 100644 --- a/crates/skill-daemon/src/activity.rs +++ b/crates/skill-daemon/src/activity.rs @@ -1494,6 +1494,16 @@ fn run_poller(state: AppState, store: Arc) { if deleted > 0 { tracing::info!("[activity] pruned {deleted} file_interactions older than {retention_days}d"); } + + // Session day directories (EEG/PPG/IMU/fNIRS recordings, + // sidecars, metrics caches, per-day SQLite + HNSW). + let (dirs_removed, dir_errors) = + crate::session::retention::prune_session_dirs(&skill_dir, retention_days, now); + if dirs_removed > 0 || dir_errors > 0 { + tracing::info!( + "[activity] pruned {dirs_removed} session day dirs older than {retention_days}d ({dir_errors} errors)" + ); + } } // Build focus sessions from recent interactions. build_focus_sessions(&store, now.saturating_sub(7200)); diff --git a/crates/skill-daemon/src/routes/settings_exg.rs b/crates/skill-daemon/src/routes/settings_exg.rs index 8e0ea6b2..37eb1fe3 100644 --- a/crates/skill-daemon/src/routes/settings_exg.rs +++ b/crates/skill-daemon/src/routes/settings_exg.rs @@ -504,10 +504,21 @@ struct RawDayData { /// Load all raw CSV data for a day directory. /// Each segment stores its own channel names read from the CSV header. +/// +/// Rows are capped per file AND across the whole day to keep memory bounded +/// once session rollover produces many chunk files per day. Without the +/// per-day cap, a 24-hour recording with hourly rollover would attempt to +/// load 24 × per-file-cap rows into RAM. fn load_day_csv_data(_day_dir: &std::path::Path, csv_files: &[std::path::PathBuf], sample_rate: f64) -> RawDayData { let mut segments = Vec::new(); + // Day-wide cap: ≈ 4.3 hours at 256 Hz, plenty for any UI preview. + const MAX_DAY_ROWS: usize = 4_000_000; + let mut day_row_count: usize = 0; for csv_path in csv_files { + if day_row_count >= MAX_DAY_ROWS { + break; + } let Ok(file) = std::fs::File::open(csv_path) else { continue; }; @@ -534,13 +545,13 @@ fn load_day_csv_data(_day_dir: &std::path::Path, csv_files: &[std::path::PathBuf } let mut channels: Vec> = vec![Vec::new(); file_ch]; let mut first_ts: Option = None; - // Cap rows to prevent OOM on very large CSV files (~4M samples at - // 256 Hz ≈ 4.3 hours, well beyond a single session). + // Per-file cap (legacy single-file-per-session safety net). The + // day-wide cap above bounds memory across rollover chunks. const MAX_ROWS: usize = 4_000_000; let mut row_count = 0usize; for line in lines.map_while(Result::ok) { - if row_count >= MAX_ROWS { + if row_count >= MAX_ROWS || day_row_count >= MAX_DAY_ROWS { break; } let fields: Vec<&str> = line.split(',').collect(); @@ -568,6 +579,7 @@ fn load_day_csv_data(_day_dir: &std::path::Path, csv_files: &[std::path::PathBuf channels[ch].push(v); } row_count += 1; + day_row_count += 1; } } @@ -1280,6 +1292,46 @@ mod tests { assert_eq!(data.segments[0].2[0].len(), 2); // only 2 valid rows } + /// Day-wide row cap holds across rollover chunk files. Build several + /// chunks whose combined row count exceeds the cap and verify that the + /// loader stops collecting rather than loading everything into memory. + #[test] + fn load_day_csv_data_caps_rows_across_chunks() { + let td = tempfile::tempdir().unwrap(); + let d = td.path(); + + // Tiny rows to keep test fast — we patch the cap behavior by + // inspecting the *aggregated* row count, not by hitting the real + // 4M cap. We assert each chunk fully loads when below the cap, then + // assert that across many chunks the total stays ≤ MAX_DAY_ROWS. + // Realistic check: produce a number well under per-file cap (4M) + // but over what we reasonably need so the test detects regressions + // in the *combined* counting logic. + let per_chunk = 1000usize; + let n_chunks = 5usize; + let mut csvs = Vec::new(); + for k in 0..n_chunks { + let start = 1_700_000_000.0 + (k as f64) * 10_000.0; + let rows = gen_rows(start, per_chunk, 4, 256.0); + let name = format!("exg_{}.csv", 1_700_000_000 + k as u64 * 10_000); + write_csv(d, &name, muse_header(), &rows); + csvs.push(d.join(&name)); + } + + let data = load_day_csv_data(d, &csvs, 256.0); + + // All segments load when total well under cap. + assert_eq!(data.segments.len(), n_chunks); + let total_samples: usize = data.segments.iter().map(|s| s.2[0].len()).sum(); + assert_eq!(total_samples, per_chunk * n_chunks); + + // The day_row_count guard short-circuits new files once the cap is + // hit. We can only exercise that branch with a giant fixture, but + // the structural change is covered by the segment-count assertion + // (every chunk was visited and loaded — i.e. the loop did not + // erroneously stop after the first file). + } + #[test] fn load_csv_nan_values_skip_row() { let td = tempfile::tempdir().unwrap(); diff --git a/crates/skill-daemon/src/session/mod.rs b/crates/skill-daemon/src/session/mod.rs index 9014ac99..9504d51e 100644 --- a/crates/skill-daemon/src/session/mod.rs +++ b/crates/skill-daemon/src/session/mod.rs @@ -10,6 +10,7 @@ mod connect; mod connect_ble; mod connect_wired; pub(crate) mod pipeline; +pub(crate) mod retention; mod runner; pub(crate) mod shared; diff --git a/crates/skill-daemon/src/session/pipeline.rs b/crates/skill-daemon/src/session/pipeline.rs index f8f44830..a7c8424c 100644 --- a/crates/skill-daemon/src/session/pipeline.rs +++ b/crates/skill-daemon/src/session/pipeline.rs @@ -272,6 +272,7 @@ impl Pipeline { pub(crate) fn finalize(&mut self) { self.writer.flush(); + self.writer.close(); write_session_meta( &self.csv_path, &self.device_name, @@ -291,6 +292,45 @@ impl Pipeline { "session finalized" ); } + + /// Roll the session writer to a new file. Finalises the current chunk + /// (writes sidecar JSON, closes Parquet footer) and opens a fresh + /// `exg_.csv|parquet`. Keeps DSP, embedding, and PPG/artifact state + /// warm — only the writer is swapped. + /// + /// To downstream readers each chunk looks identical to a normal short + /// session — same naming, same sidecar shape — so no readers need to + /// know about rollover. + pub(crate) fn roll(&mut self, skill_dir: &Path) -> anyhow::Result<()> { + // 1. Finalise the current chunk (writer flush+close, sidecar JSON). + self.finalize(); + + // 2. Compute a new csv_path. unix_secs() granularity is 1s; if a + // rollover lands inside the same second as the previous start, + // bump by 1 to keep filenames unique. + let day_dir = utc_date_dir(skill_dir); + let now = unix_secs(); + let new_start = if now > self.start_utc { now } else { self.start_utc + 1 }; + let new_path = day_dir.join(format!("exg_{new_start}.csv")); + + // 3. Open a fresh writer with the same labels and current settings. + let storage_format = { + let settings = skill_settings::load_settings(skill_dir); + StorageFormat::parse(&settings.storage_format) + }; + let labels: Vec<&str> = self.channel_names.iter().map(String::as_str).collect(); + let new_writer = SessionWriter::open(&new_path, &labels, storage_format).context("rollover writer open")?; + + // 4. Swap. + self.writer = new_writer; + self.csv_path = new_path; + self.start_utc = new_start; + self.total_samples = 0; + self.flush_counter = 0; + + info!(path = %self.csv_path.display(), "session rolled"); + Ok(()) + } } #[cfg(test)] @@ -460,4 +500,103 @@ mod tests { let q = pipe.channel_quality(); assert_eq!(q.len(), 4); } + + /// Rollover finalises the current chunk and opens a fresh one with a + /// distinct path, while preserving DSP/embed state. Both chunks must be + /// readable independently. + #[test] + fn pipeline_roll_finalizes_and_opens_new_chunk() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 2, + 128.0, + vec!["Ch1".into(), "Ch2".into()], + "RollDevice".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + + // Write some samples to chunk 1. + for i in 0..30 { + pipe.push_eeg(&[1.0, 2.0], i as f64 / 128.0); + } + let chunk1_path = pipe.csv_path.clone(); + let chunk1_start = pipe.start_utc; + + // Roll. + pipe.roll(dir.path()).unwrap(); + + // After roll: counters reset, path differs, start_utc strictly greater. + assert_eq!(pipe.total_samples, 0); + assert_ne!(pipe.csv_path, chunk1_path); + assert!(pipe.start_utc > chunk1_start, "new start_utc must advance"); + + // Write samples to chunk 2. + for i in 0..20 { + pipe.push_eeg(&[3.0, 4.0], i as f64 / 128.0); + } + assert_eq!(pipe.total_samples, 20); + let chunk2_path = pipe.csv_path.clone(); + + pipe.finalize(); + + // Both CSVs and both sidecars exist. + assert!(chunk1_path.exists(), "chunk1 csv"); + assert!(chunk2_path.exists(), "chunk2 csv"); + let m1: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(chunk1_path.with_extension("json")).unwrap()).unwrap(); + let m2: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(chunk2_path.with_extension("json")).unwrap()).unwrap(); + assert_eq!(m1["total_samples"], 30); + assert_eq!(m2["total_samples"], 20); + assert_eq!(m1["device_name"], "RollDevice"); + assert_eq!(m2["device_name"], "RollDevice"); + } + + /// Same-second rollover must still produce a unique filename. + #[test] + fn pipeline_roll_handles_subsecond_collision() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 2, + 128.0, + vec!["Ch1".into(), "Ch2".into()], + "Sub".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + + let p0 = pipe.csv_path.clone(); + pipe.roll(dir.path()).unwrap(); + let p1 = pipe.csv_path.clone(); + pipe.roll(dir.path()).unwrap(); + let p2 = pipe.csv_path.clone(); + + assert_ne!(p0, p1); + assert_ne!(p1, p2); + assert_ne!(p0, p2); + assert!(p0.exists() && p1.exists() && p2.exists()); + } } diff --git a/crates/skill-daemon/src/session/retention.rs b/crates/skill-daemon/src/session/retention.rs new file mode 100644 index 00000000..376b439a --- /dev/null +++ b/crates/skill-daemon/src/session/retention.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Session-file retention. +//! +//! Day directories (`/YYYYMMDD/`) hold all of a day's session +//! artifacts: EEG/PPG/IMU/fNIRS CSV+Parquet, sidecar JSONs, metrics caches, +//! per-day SQLite + HNSW indices. After `file_retention_days` they are +//! removed wholesale. +//! +//! Wholesale day-dir deletion is correct because every session-related +//! artifact lives inside the day dir, and the dir name itself encodes the +//! date — no per-file timestamp parsing required. + +use std::path::Path; + +/// Convert Unix seconds (UTC) to a packed `YYYYMMDD` integer using the same +/// civil-from-days arithmetic as [`crate::session::shared::utc_date_dir`]. +pub(crate) fn unix_to_yyyymmdd(secs: u64) -> u32 { + let days = (secs / 86400) as i64; + let z = days + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as u32) * 10000 + (m as u32) * 100 + (d as u32) +} + +/// Remove every day directory in `skill_dir` whose name is older than the +/// retention window. Returns `(removed, errors)`. +/// +/// `retention_days == 0` disables retention (matches the convention used +/// elsewhere in settings). +pub(crate) fn prune_session_dirs(skill_dir: &Path, retention_days: u32, now_secs: u64) -> (usize, usize) { + if retention_days == 0 { + return (0, 0); + } + let cutoff_secs = now_secs.saturating_sub(u64::from(retention_days) * 86400); + let cutoff_yyyymmdd = unix_to_yyyymmdd(cutoff_secs); + + let Ok(entries) = std::fs::read_dir(skill_dir) else { + return (0, 0); + }; + + let mut removed = 0; + let mut errors = 0; + for entry in entries.flatten() { + let Ok(ft) = entry.file_type() else { continue }; + if !ft.is_dir() { + continue; + } + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { continue }; + if name_str.len() != 8 || !name_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let Ok(dir_yyyymmdd) = name_str.parse::() else { + continue; + }; + if dir_yyyymmdd >= cutoff_yyyymmdd { + continue; + } + match std::fs::remove_dir_all(entry.path()) { + Ok(()) => removed += 1, + Err(e) => { + errors += 1; + tracing::warn!(dir = %entry.path().display(), %e, "failed to prune session day dir"); + } + } + } + (removed, errors) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn touch_dir(root: &Path, name: &str) { + let d = root.join(name); + std::fs::create_dir_all(&d).unwrap(); + // Drop a placeholder file so we exercise recursive removal. + std::fs::write(d.join("exg_1.csv"), "timestamp_s,Ch1\n0.0,0.0\n").unwrap(); + std::fs::write(d.join("exg_1.json"), r#"{"device_name":"x"}"#).unwrap(); + } + + #[test] + fn unix_to_yyyymmdd_known_dates() { + // 2024-01-01 00:00:00 UTC = 1704067200 + assert_eq!(unix_to_yyyymmdd(1_704_067_200), 20240101); + // 2025-06-15 12:00:00 UTC = 1750_000_800 — verify packing format. + let v = unix_to_yyyymmdd(1_750_000_800); + assert!((20250101..=20251231).contains(&v)); + // Epoch. + assert_eq!(unix_to_yyyymmdd(0), 19700101); + } + + #[test] + fn prune_removes_old_dirs_only() { + let td = tempfile::tempdir().unwrap(); + let root = td.path(); + // now = 2024-06-15 + let now: u64 = 1_718_409_600; + + // Old: 2024-01-01 (≈ 166 days old) + touch_dir(root, "20240101"); + // Borderline: 2024-06-14 (1 day old — keep) + touch_dir(root, "20240614"); + // Today. + touch_dir(root, "20240615"); + // Non-day dir: must NOT be touched. + touch_dir(root, "logs"); + // 8-digit non-numeric: must NOT be touched. + std::fs::create_dir_all(root.join("notadate")).unwrap(); + std::fs::write(root.join("notadate/marker"), "").unwrap(); + + let (removed, errors) = prune_session_dirs(root, 30, now); + assert_eq!(errors, 0); + assert_eq!(removed, 1, "only the 2024-01-01 dir should be pruned"); + + assert!(!root.join("20240101").exists()); + assert!(root.join("20240614").exists()); + assert!(root.join("20240615").exists()); + assert!(root.join("logs").exists()); + assert!(root.join("notadate").exists()); + } + + #[test] + fn prune_disabled_when_retention_zero() { + let td = tempfile::tempdir().unwrap(); + touch_dir(td.path(), "20200101"); + let now: u64 = 1_718_409_600; + + let (removed, errors) = prune_session_dirs(td.path(), 0, now); + assert_eq!(removed, 0); + assert_eq!(errors, 0); + assert!(td.path().join("20200101").exists()); + } + + #[test] + fn prune_handles_missing_dir() { + let td = tempfile::tempdir().unwrap(); + let missing = td.path().join("does_not_exist"); + let (removed, errors) = prune_session_dirs(&missing, 30, 1_718_409_600); + assert_eq!(removed, 0); + assert_eq!(errors, 0); + } +} diff --git a/crates/skill-daemon/src/session/runner.rs b/crates/skill-daemon/src/session/runner.rs index 4b31c687..f2e85ff8 100644 --- a/crates/skill-daemon/src/session/runner.rs +++ b/crates/skill-daemon/src/session/runner.rs @@ -37,6 +37,25 @@ pub(crate) async fn run_adapter_session( let idle_sleep = tokio::time::sleep(IDLE_TIMEOUT); tokio::pin!(idle_sleep); + // Session rollover: bound the blast radius of a daemon crash to ≤ N + // minutes of data, and keep individual files small enough for readers. + // Configurable via `session_rollover_minutes` (0 = disabled). + let rollover_secs: u64 = { + let settings = skill_settings::load_settings(&skill_dir); + u64::from(settings.session_rollover_minutes).saturating_mul(60) + }; + // When disabled, use a finite-but-effectively-infinite duration. The + // select arm gates on `rollover_secs > 0` so the sleep is never read, + // but the Sleep future still exists and its deadline must not overflow + // tokio's internal Instant arithmetic — so cap at ~10 years. + let rollover_duration = if rollover_secs == 0 { + std::time::Duration::from_secs(60 * 60 * 24 * 365 * 10) + } else { + std::time::Duration::from_secs(rollover_secs) + }; + let rollover_sleep = tokio::time::sleep(rollover_duration); + tokio::pin!(rollover_sleep); + loop { tokio::select! { biased; @@ -55,6 +74,16 @@ pub(crate) async fn run_adapter_session( broadcast_event(&state.events_tx, "DeviceDisconnected", &serde_json::json!({"reason": "idle_timeout"})); break; } + () = &mut rollover_sleep, if rollover_secs > 0 && pipeline.is_some() => { + if let Some(ref mut pipe) = pipeline { + if let Err(e) = pipe.roll(&skill_dir) { + error!(%e, "session rollover failed; continuing on existing writer"); + } else if let Ok(mut s) = state.status.lock() { + s.csv_path = Some(pipe.csv_path.display().to_string()); + } + } + rollover_sleep.as_mut().reset(tokio::time::Instant::now() + rollover_duration); + } ev = adapter.next_event() => { // Reset idle timer on every event. idle_sleep.as_mut().reset(tokio::time::Instant::now() + IDLE_TIMEOUT); @@ -127,6 +156,7 @@ pub(crate) async fn run_adapter_session( s.csv_path = Some(p.csv_path.display().to_string()); } pipeline = Some(p); + rollover_sleep.as_mut().reset(tokio::time::Instant::now() + rollover_duration); } Err(e) => error!(%e, "pipeline open failed"), } @@ -1900,4 +1930,119 @@ mod tests { .saturating_sub(1); // minus header assert_eq!(csv_rows, expected_imu_frames as usize, "IMU CSV row count mismatch"); } + + // ── Rollover: long-running session produces multiple chunk files ────────── + + fn write_settings_with_rollover(skill_dir: &std::path::Path, minutes: u32) { + let mut s = skill_settings::UserSettings::default(); + s.session_rollover_minutes = minutes; + let p = skill_settings::settings_path(skill_dir); + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&p, serde_json::to_string_pretty(&s).unwrap()).unwrap(); + } + + /// With `session_rollover_minutes = 1`, a session that runs ≥ 60 fake-time + /// seconds should produce at least two raw EEG CSV chunks. `start_paused` + /// makes tokio auto-advance virtual time across `tokio::time::sleep` calls + /// so the test runs in real-milliseconds. + #[tokio::test(start_paused = true)] + async fn session_rolls_over_after_configured_interval() { + let dir = TempDir::new().unwrap(); + write_settings_with_rollover(dir.path(), 1); + let state = test_state(dir.path()); + + // Each adapter event carries a 2 s fake-time delay → 40 events ≈ 80 s, + // well past the 60 s rollover threshold. + let mut adapter = MockAdapter::new(eeg_desc("muse", 4, 256.0)).with_delay(Duration::from_secs(2)); + adapter.push(DeviceEvent::Connected(DeviceInfo { + name: "Muse-Roll".to_string(), + id: "mock:roll".to_string(), + firmware_version: Some("1.0.0".to_string()), + ..Default::default() + })); + for i in 0..40 { + adapter.push(DeviceEvent::Eeg(EegFrame { + channels: vec![1.0, 2.0, 3.0, 4.0], + timestamp_s: i as f64 / 256.0, + })); + } + adapter.push(DeviceEvent::Disconnected); + + run(state, adapter).await; + + // Count raw EEG chunk CSVs (excluding suffixed companions). + let chunks: Vec<_> = files_recursive(dir.path()) + .into_iter() + .filter(|p| { + let s = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + s.starts_with("exg_") + && s.ends_with(".csv") + && !s.contains("_imu") + && !s.contains("_ppg") + && !s.contains("_metrics") + && !s.contains("_fnirs") + }) + .collect(); + + assert!( + chunks.len() >= 2, + "expected ≥2 EEG chunk CSVs after rollover, got {}: {:?}", + chunks.len(), + chunks + ); + + // Each chunk has its own sidecar JSON, both readable and well-formed. + for chunk in &chunks { + let sidecar = chunk.with_extension("json"); + assert!(sidecar.exists(), "missing sidecar for {chunk:?}"); + let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap(); + assert_eq!(v["device_name"], "Muse-Roll"); + } + } + + /// With `session_rollover_minutes = 0`, rollover is disabled — even a + /// long-running session must produce exactly one chunk. + #[tokio::test(start_paused = true)] + async fn rollover_disabled_keeps_single_chunk() { + let dir = TempDir::new().unwrap(); + write_settings_with_rollover(dir.path(), 0); + let state = test_state(dir.path()); + + let mut adapter = MockAdapter::new(eeg_desc("muse", 4, 256.0)).with_delay(Duration::from_secs(2)); + adapter.push(DeviceEvent::Connected(DeviceInfo { + name: "Muse-NoRoll".to_string(), + id: "mock:noroll".to_string(), + ..Default::default() + })); + for i in 0..40 { + adapter.push(DeviceEvent::Eeg(EegFrame { + channels: vec![1.0, 2.0, 3.0, 4.0], + timestamp_s: i as f64 / 256.0, + })); + } + adapter.push(DeviceEvent::Disconnected); + + run(state, adapter).await; + + let chunks: Vec<_> = files_recursive(dir.path()) + .into_iter() + .filter(|p| { + let s = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + s.starts_with("exg_") + && s.ends_with(".csv") + && !s.contains("_imu") + && !s.contains("_ppg") + && !s.contains("_metrics") + && !s.contains("_fnirs") + }) + .collect(); + + assert_eq!( + chunks.len(), + 1, + "expected exactly 1 chunk with rollover disabled, got {chunks:?}" + ); + } } diff --git a/crates/skill-data/src/session_parquet.rs b/crates/skill-data/src/session_parquet.rs index 375a4dee..3d237d9b 100644 --- a/crates/skill-data/src/session_parquet.rs +++ b/crates/skill-data/src/session_parquet.rs @@ -52,7 +52,7 @@ fn writer_props() -> WriterProperties { /// and `flush` in the same way. pub struct ParquetState { // ── EEG ────────────────────────────────────────────────────────────────── - eeg_wtr: ArrowWriter, + eeg_wtr: Option>, eeg_schema: Arc, n_eeg: usize, eeg_ts: Vec>, @@ -151,7 +151,7 @@ impl ParquetState { let imu_schema = Arc::new(Schema::new(imu_fields)); Ok(Self { - eeg_wtr, + eeg_wtr: Some(eeg_wtr), eeg_schema, n_eeg: n, eeg_ts: (0..n).map(|_| VecDeque::new()).collect(), @@ -226,12 +226,16 @@ impl ParquetState { } if let Ok(batch) = RecordBatch::try_new(self.eeg_schema.clone(), columns) { - let _ = self.eeg_wtr.write(&batch); + if let Some(ref mut w) = self.eeg_wtr { + let _ = w.write(&batch); + } self.eeg_rows += ready; } if self.eeg_rows >= EEG_FLUSH_ROWS { - let _ = self.eeg_wtr.flush(); + if let Some(ref mut w) = self.eeg_wtr { + let _ = w.flush(); + } self.eeg_rows = 0; } } @@ -609,7 +613,9 @@ impl ParquetState { } pub fn flush(&mut self) { - let _ = self.eeg_wtr.flush(); + if let Some(ref mut w) = self.eeg_wtr { + let _ = w.flush(); + } if let Some(ref mut w) = self.ppg_wtr { let _ = w.flush(); } @@ -618,24 +624,36 @@ impl ParquetState { self.flush_fnirs(); } - /// Close all writers, finalising the Parquet files. - pub fn close(mut self) { + /// Close all writers, finalising the Parquet files. Idempotent. + /// + /// A Parquet file is invalid until its footer is written by `close()`. + /// This is also called from `Drop`, so a daemon panic or unexpected + /// shutdown won't leave a footerless file. + pub fn close(&mut self) { self.flush_metrics(); self.flush_imu(); self.flush_fnirs(); - let _ = self.eeg_wtr.close(); - if let Some(w) = self.ppg_wtr { + if let Some(w) = self.eeg_wtr.take() { let _ = w.close(); } - if let Some(w) = self.metrics_wtr { + if let Some(w) = self.ppg_wtr.take() { let _ = w.close(); } - if let Some(w) = self.imu_wtr { + if let Some(w) = self.metrics_wtr.take() { let _ = w.close(); } - if let Some(w) = self.fnirs_wtr { + if let Some(w) = self.imu_wtr.take() { let _ = w.close(); } + if let Some(w) = self.fnirs_wtr.take() { + let _ = w.close(); + } + } +} + +impl Drop for ParquetState { + fn drop(&mut self) { + self.close(); } } @@ -712,6 +730,63 @@ mod tests { assert!(ppg_path.exists(), "Parquet PPG file should exist"); } + #[test] + fn parquet_readable_after_drop_without_explicit_close() { + use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; + + let dir = tempfile::tempdir().unwrap(); + let csv_path = dir.path().join("exg_drop.csv"); + let labels = ["TP9", "AF7", "AF8", "TP10"]; + + let pq_path = eeg_parquet_path(&csv_path); + let ppg_path = ppg_parquet_path(&csv_path); + + { + let mut pq = ParquetState::open_with_labels(&csv_path, &labels).unwrap(); + for i in 0..10 { + let s = [i as f64; 4]; + pq.push_eeg(0, &s, 1000.0 + i as f64, 256.0); + pq.push_eeg(1, &s, 1000.0 + i as f64, 256.0); + pq.push_eeg(2, &s, 1000.0 + i as f64, 256.0); + pq.push_eeg(3, &s, 1000.0 + i as f64, 256.0); + } + // Force PPG writer creation so Drop must close it too. + let ppg = [500.0, 501.0]; + pq.push_ppg(&csv_path, 0, &ppg, 1000.0, None); + pq.push_ppg(&csv_path, 1, &ppg, 1000.0, None); + pq.push_ppg(&csv_path, 2, &ppg, 1000.0, None); + // Intentionally drop without calling close() — Drop must finalise. + } + + let f = std::fs::File::open(&pq_path).expect("parquet exists"); + let builder = ParquetRecordBatchReaderBuilder::try_new(f).expect("eeg footer readable"); + let reader = builder.build().unwrap(); + let total_rows: usize = reader.flatten().map(|b| b.num_rows()).sum(); + assert!(total_rows > 0, "should have read EEG rows back"); + + let f = std::fs::File::open(&ppg_path).expect("ppg parquet exists"); + let builder = ParquetRecordBatchReaderBuilder::try_new(f).expect("ppg footer readable"); + let _ = builder.build().unwrap(); + } + + #[test] + fn parquet_close_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let csv_path = dir.path().join("exg_close.csv"); + let labels = ["TP9", "AF7", "AF8", "TP10"]; + + let mut pq = ParquetState::open_with_labels(&csv_path, &labels).unwrap(); + let s = [1.0; 4]; + pq.push_eeg(0, &s, 1000.0, 256.0); + pq.push_eeg(1, &s, 1000.0, 256.0); + pq.push_eeg(2, &s, 1000.0, 256.0); + pq.push_eeg(3, &s, 1000.0, 256.0); + + pq.close(); + pq.close(); + // Drop runs another close — must not panic. + } + #[test] fn parquet_imu_creates_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/skill-data/src/session_writer.rs b/crates/skill-data/src/session_writer.rs index 81c73d48..bc8d01bf 100644 --- a/crates/skill-data/src/session_writer.rs +++ b/crates/skill-data/src/session_writer.rs @@ -124,6 +124,19 @@ impl SessionWriter { pub fn flush(&mut self) { dispatch!(self, flush()); } + + /// Finalise underlying writers (writes Parquet footer; no-op for CSV). + /// Idempotent. Drop also calls this for crash safety, but calling it + /// explicitly gives deterministic ordering and surfaces errors via logs. + pub fn close(&mut self) { + match self { + Self::Csv(_) => {} + #[cfg(feature = "parquet")] + Self::Parquet(p) => p.close(), + #[cfg(feature = "parquet")] + Self::Both(_, p) => p.close(), + } + } } #[cfg(test)] diff --git a/crates/skill-settings/src/lib.rs b/crates/skill-settings/src/lib.rs index 7fde7a36..9f3168bc 100644 --- a/crates/skill-settings/src/lib.rs +++ b/crates/skill-settings/src/lib.rs @@ -948,6 +948,11 @@ pub struct UserSettings { /// Recording storage format: `"csv"` (default), `"parquet"`, or `"both"`. #[serde(default = "default_storage_format")] pub storage_format: String, + /// Roll the session writer to a new file every N minutes. Bounds the + /// blast radius of a daemon crash to ≤ N minutes of data and keeps any + /// single file small enough for readers to load. `0` disables rollover. + #[serde(default = "default_session_rollover_minutes")] + pub session_rollover_minutes: u32, /// Background scanner backend toggles. #[serde(default)] pub scanner: ScannerConfig, @@ -1097,6 +1102,10 @@ pub fn default_storage_format() -> String { "csv".into() } +pub fn default_session_rollover_minutes() -> u32 { + 60 +} + pub fn default_tts_preload() -> bool { true } @@ -1257,6 +1266,7 @@ impl Default for UserSettings { llm: LlmConfig::default(), accent_color: default_accent_color(), storage_format: default_storage_format(), + session_rollover_minutes: default_session_rollover_minutes(), screenshot: ScreenshotConfig::default(), sleep: SleepConfig::default(), scanner: ScannerConfig::default(), From fcc54a8e956ce976ed4453c6f18b40259e037cea Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Tue, 5 May 2026 00:29:27 -0400 Subject: [PATCH 35/98] roll csv/parquet --- .claude/scheduled_tasks.lock | 1 + crates/skill-daemon/src/session/runner.rs | 74 +++++++++++++++++ crates/skill-data/src/session_csv.rs | 12 ++- crates/skill-data/src/session_parquet.rs | 1 + crates/skill-devices/src/lib.rs | 1 + crates/skill-eeg/src/band_metrics.rs | 96 +++++++++++++++++++++++ crates/skill-eeg/src/eeg_bands.rs | 8 ++ crates/skill-eeg/src/eeg_model_config.rs | 1 + crates/skill-exg/src/lib.rs | 4 + crates/skill-history/src/cache.rs | 7 ++ crates/skill-history/src/lib.rs | 2 + crates/skill-history/src/metrics.rs | 10 +++ crates/skill-router/src/lib.rs | 1 + src/lib/charts/BandChart.svelte | 1 + src/lib/compare/compare-types.ts | 2 + src/lib/dashboard/EegIndices.svelte | 3 + src/lib/dashboard/SessionDetail.svelte | 4 + src/lib/i18n/de/dashboard.ts | 1 + src/lib/i18n/de/history.ts | 2 + src/lib/i18n/de/ui.ts | 1 + src/lib/i18n/en/dashboard.ts | 1 + src/lib/i18n/en/history.ts | 2 + src/lib/i18n/en/ui.ts | 1 + src/lib/i18n/es/dashboard.ts | 1 + src/lib/i18n/es/history.ts | 2 + src/lib/i18n/es/ui.ts | 1 + src/lib/i18n/fr/dashboard.ts | 1 + src/lib/i18n/fr/history.ts | 2 + src/lib/i18n/fr/ui.ts | 1 + src/lib/i18n/he/dashboard.ts | 1 + src/lib/i18n/he/history.ts | 2 + src/lib/i18n/he/ui.ts | 1 + src/lib/i18n/ja/dashboard.ts | 1 + src/lib/i18n/ja/history.ts | 2 + src/lib/i18n/ja/ui.ts | 1 + src/lib/i18n/keys.ts | 4 + src/lib/i18n/ko/dashboard.ts | 1 + src/lib/i18n/ko/history.ts | 2 + src/lib/i18n/ko/ui.ts | 1 + src/lib/i18n/uk/dashboard.ts | 1 + src/lib/i18n/uk/history.ts | 2 + src/lib/i18n/uk/ui.ts | 1 + src/lib/i18n/zh/dashboard.ts | 1 + src/lib/i18n/zh/history.ts | 2 + src/lib/i18n/zh/ui.ts | 1 + src/routes/+page.svelte | 3 + src/tests/virtual-device-e2e.spec.ts | 1 + 47 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..16e40282 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"154accaa-5c68-472a-b9b9-3e27e3ac4a76","pid":78020,"procStart":"Mon May 4 21:06:36 2026","acquiredAt":1777939737118} \ No newline at end of file diff --git a/crates/skill-daemon/src/session/runner.rs b/crates/skill-daemon/src/session/runner.rs index f2e85ff8..140f6e1e 100644 --- a/crates/skill-daemon/src/session/runner.rs +++ b/crates/skill-daemon/src/session/runner.rs @@ -1943,6 +1943,19 @@ mod tests { std::fs::write(&p, serde_json::to_string_pretty(&s).unwrap()).unwrap(); } + /// Locate the single `YYYYMMDD/` day directory under `root`. + fn find_day_dir(root: &std::path::Path) -> Option { + std::fs::read_dir(root).ok()?.flatten().find_map(|e| { + let p = e.path(); + let name = p.file_name()?.to_str()?.to_string(); + if p.is_dir() && name.len() == 8 && name.chars().all(|c| c.is_ascii_digit()) { + Some(p) + } else { + None + } + }) + } + /// With `session_rollover_minutes = 1`, a session that runs ≥ 60 fake-time /// seconds should produce at least two raw EEG CSV chunks. `start_paused` /// makes tokio auto-advance virtual time across `tokio::time::sleep` calls @@ -2000,6 +2013,67 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap(); assert_eq!(v["device_name"], "Muse-Roll"); } + + // ── E2E: real consumer code paths must accept rolled-over chunks ── + // + // Build env can't link the bin (llama-cpp-sys-4 native lib mismatch), + // so instead of going over HTTP we drive the same in-process Rust + // readers the HTTP routes call. This is the layer where rollover + // could break behaviour — HTTP is just transport. + + let day_dir = find_day_dir(dir.path()).expect("session day dir created"); + let day = day_dir.file_name().unwrap().to_str().unwrap().to_string(); + + // 1. History listing — used by /v1/history/sessions and the frontend + // history page. Must return one entry per chunk with sane fields. + let entries = skill_history::list_sessions_for_day(&day, dir.path(), None); + assert_eq!( + entries.len(), + chunks.len(), + "list_sessions_for_day count must match chunk count" + ); + for e in &entries { + assert_eq!(e.device_name.as_deref(), Some("Muse-Roll")); + assert!( + std::path::Path::new(&e.csv_path).exists(), + "csv_path from sidecar must resolve: {}", + e.csv_path + ); + assert!(e.session_start_utc.is_some(), "session_start_utc populated"); + assert!( + e.firmware_version.as_deref() == Some("1.0.0"), + "firmware_version threaded through to entry" + ); + } + // Entries are sorted by session_start_utc ascending or descending — + // enforce strict monotonicity so the UI shows a consistent order + // across rollover boundaries. + let starts: Vec = entries.iter().filter_map(|e| e.session_start_utc).collect(); + assert_eq!(starts.len(), entries.len(), "every entry has a start time"); + let monotonic_inc = starts.windows(2).all(|w| w[0] <= w[1]); + let monotonic_dec = starts.windows(2).all(|w| w[0] >= w[1]); + assert!( + monotonic_inc || monotonic_dec, + "session list must be monotonically ordered: {starts:?}" + ); + + // 2. Per-day SQLite (search index) is created for the day, not per + // session — so embeddings written by a chunk land in the same + // file as embeddings from sibling chunks. Embedding is skipped + // in #[cfg(test)], but the file should still get opened by + // EpochStore on first epoch flush. + let sqlite_path = day_dir.join(skill_constants::SQLITE_FILE); + if sqlite_path.exists() { + // Day SQLite is a single file across chunks — the rollover + // does NOT create a new SQLite. Verify by counting hits and + // confirming there is exactly one file. + let count = std::fs::read_dir(&day_dir) + .unwrap() + .flatten() + .filter(|e| e.file_name() == sqlite_path.file_name().unwrap()) + .count(); + assert_eq!(count, 1, "exactly one per-day SQLite, regardless of chunks"); + } } /// With `session_rollover_minutes = 0`, rollover is disabled — even a diff --git a/crates/skill-data/src/session_csv.rs b/crates/skill-data/src/session_csv.rs index 7638ca06..d3896571 100644 --- a/crates/skill-data/src/session_csv.rs +++ b/crates/skill-data/src/session_csv.rs @@ -67,7 +67,7 @@ pub fn fnirs_csv_path(eeg_path: &Path) -> PathBuf { /// - 3 GPU utilisation /// /// Cross-channel metric column names (after the per-channel band powers). -pub const METRICS_CROSS_CHANNEL_HEADER: [&str; 46] = [ +pub const METRICS_CROSS_CHANNEL_HEADER: [&str; 47] = [ // ── Cross-channel EEG indices ── "faa", "tar", @@ -121,6 +121,10 @@ pub const METRICS_CROSS_CHANNEL_HEADER: [&str; 46] = [ "gpu_overall_pct", "gpu_render_pct", "gpu_tiler_pct", + // ── Phase metrics ── + // Appended at the end so older recordings (without this column) remain + // readable with the existing fixed-offset parser in skill-history. + "echt", ]; /// Band-power suffixes for each channel (6 absolute + 6 relative = 12 per channel). @@ -158,7 +162,7 @@ pub fn build_metrics_header(channel_names: &[&str]) -> Vec { } /// Legacy fixed header for 4-channel Muse (kept for backward-compat reading). -pub const METRICS_CSV_HEADER: [&str; 95] = [ +pub const METRICS_CSV_HEADER: [&str; 96] = [ "timestamp_s", "TP9_delta", "TP9_theta", @@ -254,6 +258,7 @@ pub const METRICS_CSV_HEADER: [&str; 95] = [ "gpu_overall_pct", "gpu_render_pct", "gpu_tiler_pct", + "echt", ]; // ── CSV writer ──────────────────────────────────────────────────────────────── @@ -658,6 +663,9 @@ impl CsvState { row.push(opt_f64(snap.gpu_render)); row.push(opt_f64(snap.gpu_tiler)); + // Phase metrics (appended at end for backward-compat). + row.push(format!("{:.6}", snap.echt)); + let refs: Vec<&str> = row.iter().map(String::as_str).collect(); let _ = wtr.write_record(&refs); self.metrics_written += 1; diff --git a/crates/skill-data/src/session_parquet.rs b/crates/skill-data/src/session_parquet.rs index 3d237d9b..084af6f2 100644 --- a/crates/skill-data/src/session_parquet.rs +++ b/crates/skill-data/src/session_parquet.rs @@ -428,6 +428,7 @@ impl ParquetState { row.extend_from_slice(&[opt(snap.meditation), opt(snap.cognitive_load), opt(snap.drowsiness)]); row.push(opt_u16(snap.temperature_raw)); row.extend_from_slice(&[opt(snap.gpu_overall), opt(snap.gpu_render), opt(snap.gpu_tiler)]); + row.push(snap.echt as f64); self.metrics_pending.push(row); self.metrics_rows += 1; diff --git a/crates/skill-devices/src/lib.rs b/crates/skill-devices/src/lib.rs index 186315f9..99ecd21f 100644 --- a/crates/skill-devices/src/lib.rs +++ b/crates/skill-devices/src/lib.rs @@ -579,6 +579,7 @@ mod tests { sample_entropy: 0.4, pac_theta_gamma: 0.1, laterality_index: 0.05, + echt: 0.5, headache_index: 10.0, migraine_index: 5.0, consciousness_lzc: 50.0, diff --git a/crates/skill-eeg/src/band_metrics.rs b/crates/skill-eeg/src/band_metrics.rs index 4e007e71..57777362 100644 --- a/crates/skill-eeg/src/band_metrics.rs +++ b/crates/skill-eeg/src/band_metrics.rs @@ -410,6 +410,70 @@ pub(crate) fn laterality_index_fn(ch: &[BandPowers]) -> f32 { } } +/// Endpoint-Corrected Hilbert Transform — alpha-band rhythmicity (0–1). +/// +/// Estimates instantaneous phase of the alpha band (≈10 Hz) via a **causal** +/// complex-Morlet kernel: only past samples contribute to each estimate, so the +/// phase at the most recent sample is not corrupted by missing future samples +/// (the failure mode of FFT-based Hilbert at the buffer edge). +/// +/// Returns the resultant length of the detrended phase sequence — i.e. how +/// concentrated the inter-sample phase is around the expected advance +/// `2π·f0/sr`. 1.0 = perfectly rhythmic alpha; 0.0 = phase-random. +/// +/// Reference: Schreglmann et al., *Nat. Commun.* 12:363 (2021), +/// doi:10.1038/s41467-020-20581-7. +pub(crate) fn echt_fn(x: &[f32], sr: f32) -> f32 { + if sr <= 0.0 || x.len() < 64 { + return 0.0; + } + let f0: f32 = 10.0; // alpha center + let cycles: f32 = 5.0; // ≈ 2 Hz bandwidth + let kernel_len = (((cycles / f0) * sr).round() as usize).clamp(8, x.len() / 2); + let omega = 2.0 * std::f32::consts::PI * f0 / sr; + let sigma = kernel_len as f32 / 6.0; + let mid = kernel_len as f32 / 2.0; + let two_sig2 = 2.0 * sigma * sigma; + + // Precompute the causal complex-Morlet kernel. + let mut k_re = vec![0.0f32; kernel_len]; + let mut k_im = vec![0.0f32; kernel_len]; + for j in 0..kernel_len { + let t = j as f32; + let env = (-((t - mid).powi(2)) / two_sig2).exp(); + let arg = omega * t; + // Demodulator kernel exp(-iωt): negate imaginary part to shift the + // positive-frequency component down to baseband. + k_re[j] = env * arg.cos(); + k_im[j] = -env * arg.sin(); + } + + let mut cs_acc = 0.0f32; + let mut sn_acc = 0.0f32; + let mut count: u32 = 0; + for k in (kernel_len - 1)..x.len() { + let mut re = 0.0f32; + let mut im = 0.0f32; + let base = k + 1 - kernel_len; + for j in 0..kernel_len { + let xv = x[base + j]; + re += xv * k_re[j]; + im += xv * k_im[j]; + } + // Detrend by subtracting the expected phase advance ω·k so that a + // perfectly rhythmic oscillation maps to a constant residual phase. + let phase = im.atan2(re) - omega * k as f32; + cs_acc += phase.cos(); + sn_acc += phase.sin(); + count += 1; + } + if count == 0 { + return 0.0; + } + let n = count as f32; + ((cs_acc / n).powi(2) + (sn_acc / n).powi(2)).sqrt().clamp(0.0, 1.0) +} + /// Simple linear regression slope. fn lin_reg_slope(x: &[f64], y: &[f64]) -> f64 { let n = x.len() as f64; @@ -506,4 +570,36 @@ mod tests { let hfd = higuchi_fd(&signal); assert!(hfd > 0.0 && hfd < 3.0, "HFD={hfd} should be between 0 and 3"); } + + #[test] + fn echt_pure_alpha_is_rhythmic() { + // 10 Hz sinusoid sampled at 256 Hz → ECHT should be close to 1. + let sr = 256.0_f32; + let signal: Vec = (0..512) + .map(|i| (2.0 * std::f32::consts::PI * 10.0 * i as f32 / sr).sin()) + .collect(); + let r = echt_fn(&signal, sr); + assert!(r > 0.9, "pure 10 Hz sine should give R>0.9, got {r}"); + } + + #[test] + fn echt_white_noise_is_low() { + // Deterministic pseudo-random sequence (LCG) → low rhythmicity. + let mut s: u32 = 1; + let signal: Vec = (0..512) + .map(|_| { + s = s.wrapping_mul(1664525).wrapping_add(1013904223); + (s as f32 / u32::MAX as f32) - 0.5 + }) + .collect(); + let r = echt_fn(&signal, 256.0); + assert!(r < 0.5, "white-noise ECHT should be low, got {r}"); + } + + #[test] + fn echt_short_or_invalid_input() { + assert_eq!(echt_fn(&[], 256.0), 0.0); + assert_eq!(echt_fn(&[0.0; 32], 256.0), 0.0); + assert_eq!(echt_fn(&[0.0; 256], 0.0), 0.0); + } } diff --git a/crates/skill-eeg/src/eeg_bands.rs b/crates/skill-eeg/src/eeg_bands.rs index 283aa922..1635c4cf 100644 --- a/crates/skill-eeg/src/eeg_bands.rs +++ b/crates/skill-eeg/src/eeg_bands.rs @@ -208,6 +208,10 @@ pub struct BandSnapshot { /// Laterality Index — generalised L/R asymmetry across all bands. [Homan 1987] pub laterality_index: f32, + /// Endpoint-Corrected Hilbert Transform — alpha-band rhythmicity (0–1) + /// from causal-Morlet instantaneous phase. [Schreglmann et al. 2021] + pub echt: f32, + // ── Headache / Migraine EEG correlate indices (0–100) ─────────────────── // Research biomarkers derived from published literature. // NOT clinical diagnostic tools — for informational/research purposes only. @@ -840,6 +844,7 @@ impl BandAnalyzer { let mut dfa_sum = 0.0f32; let mut se_sum = 0.0f32; let mut pac_sum = 0.0f32; + let mut echt_sum = 0.0f32; for ch_idx in 0..EEG_CHANNELS { let raw: Vec = self.window[ch_idx].iter().copied().collect(); let (ha, hm, hc) = hjorth_params(&raw); @@ -851,6 +856,7 @@ impl BandAnalyzer { dfa_sum += dfa_exponent(&raw); se_sum += sample_entropy_fn(&raw); pac_sum += pac_theta_gamma_fn(&raw, self.sample_rate); + echt_sum += echt_fn(&raw, self.sample_rate); } let hjorth_activity = ha_sum / safe_nch; let hjorth_mobility = hm_sum / safe_nch; @@ -860,6 +866,7 @@ impl BandAnalyzer { let dfa_exponent_val = dfa_sum / safe_nch; let sample_entropy_val = se_sum / safe_nch; let pac_theta_gamma = pac_sum / safe_nch; + let echt = echt_sum / safe_nch; // ── Laterality Index ───────────────────────────────────────────────── let laterality_index = laterality_index_fn(&ch_powers); @@ -989,6 +996,7 @@ impl BandAnalyzer { sample_entropy: sample_entropy_val, pac_theta_gamma, laterality_index, + echt, headache_index, migraine_index, consciousness_lzc, diff --git a/crates/skill-eeg/src/eeg_model_config.rs b/crates/skill-eeg/src/eeg_model_config.rs index 765f83a6..2dac723c 100644 --- a/crates/skill-eeg/src/eeg_model_config.rs +++ b/crates/skill-eeg/src/eeg_model_config.rs @@ -348,6 +348,7 @@ pub struct LatestEpochMetrics { pub sample_entropy: f32, pub pac_theta_gamma: f32, pub laterality_index: f32, + pub echt: f32, // PPG-derived pub hr: f64, pub rmssd: f64, diff --git a/crates/skill-exg/src/lib.rs b/crates/skill-exg/src/lib.rs index 840df519..31a1897b 100644 --- a/crates/skill-exg/src/lib.rs +++ b/crates/skill-exg/src/lib.rs @@ -748,6 +748,7 @@ pub struct EpochMetrics { pub sample_entropy: f32, pub pac_theta_gamma: f32, pub laterality_index: f32, + pub echt: f32, pub hr: f64, pub rmssd: f64, pub sdnn: f64, @@ -848,6 +849,7 @@ impl EpochMetrics { sample_entropy: snap.sample_entropy, pac_theta_gamma: snap.pac_theta_gamma, laterality_index: snap.laterality_index, + echt: snap.echt, hr: 0.0, rmssd: 0.0, sdnn: 0.0, @@ -915,6 +917,7 @@ impl Default for EpochMetrics { sample_entropy: 0.0, pac_theta_gamma: 0.0, laterality_index: 0.0, + echt: 0.0, hr: 0.0, rmssd: 0.0, sdnn: 0.0, @@ -1180,6 +1183,7 @@ mod tests { sample_entropy: 0.4, pac_theta_gamma: 0.1, laterality_index: 0.05, + echt: 0.5, headache_index: 10.0, migraine_index: 5.0, consciousness_lzc: 50.0, diff --git a/crates/skill-history/src/cache.rs b/crates/skill-history/src/cache.rs index 97d85d92..addb13f2 100644 --- a/crates/skill-history/src/cache.rs +++ b/crates/skill-history/src/cache.rs @@ -207,6 +207,8 @@ struct MetricsBlob { #[serde(default, deserialize_with = "null_as_zero")] laterality_index: f64, #[serde(default, deserialize_with = "null_as_zero")] + echt: f64, + #[serde(default, deserialize_with = "null_as_zero")] hr: f64, #[serde(default, deserialize_with = "null_as_zero")] rmssd: f64, @@ -324,6 +326,7 @@ impl MetricsBlob { se: self.sample_entropy, pac: self.pac_theta_gamma, lat: self.laterality_index, + echt: self.echt, hr: self.hr, rmssd: self.rmssd, sdnn: self.sdnn, @@ -380,6 +383,7 @@ impl MetricsBlob { total.sample_entropy += self.sample_entropy; total.pac_theta_gamma += self.pac_theta_gamma; total.laterality_index += self.laterality_index; + total.echt += self.echt; total.hr += self.hr; total.rmssd += self.rmssd; total.sdnn += self.sdnn; @@ -704,6 +708,7 @@ pub fn get_session_metrics(skill_dir: &Path, start_utc: u64, end_utc: u64) -> Se total.sample_entropy /= n; total.pac_theta_gamma /= n; total.laterality_index /= n; + total.echt /= n; total.hr /= n; total.rmssd /= n; total.sdnn /= n; @@ -1501,6 +1506,7 @@ struct MetricsBlobOut { sample_entropy: f64, pac_theta_gamma: f64, laterality_index: f64, + echt: f64, hr: f64, rmssd: f64, sdnn: f64, @@ -1555,6 +1561,7 @@ fn epoch_row_to_metrics_json(row: &EpochRow) -> String { sample_entropy: row.se, pac_theta_gamma: row.pac, laterality_index: row.lat, + echt: row.echt, hr: row.hr, rmssd: row.rmssd, sdnn: row.sdnn, diff --git a/crates/skill-history/src/lib.rs b/crates/skill-history/src/lib.rs index f131a34e..8f281ca6 100644 --- a/crates/skill-history/src/lib.rs +++ b/crates/skill-history/src/lib.rs @@ -312,6 +312,7 @@ pub struct SessionMetrics { pub sample_entropy: f64, pub pac_theta_gamma: f64, pub laterality_index: f64, + pub echt: f64, pub hr: f64, pub rmssd: f64, pub sdnn: f64, @@ -370,6 +371,7 @@ pub struct EpochRow { pub se: f64, pub pac: f64, pub lat: f64, + pub echt: f64, pub mood: f64, pub hr: f64, pub rmssd: f64, diff --git a/crates/skill-history/src/metrics.rs b/crates/skill-history/src/metrics.rs index a3f3f14e..1f46762a 100644 --- a/crates/skill-history/src/metrics.rs +++ b/crates/skill-history/src/metrics.rs @@ -132,6 +132,9 @@ pub fn load_metrics_csv(csv_path: &Path) -> Option { let gpu_v = f(x + 43); let gpu_r_v = f(x + 44); let gpu_t_v = f(x + 45); + // echt is appended at the end of the cross-channel block; missing in + // older recordings → f() returns 0.0 silently. + let echt_v = f(x + 46); let mut sr = 0.0f64; let mut se2 = 0.0f64; @@ -182,6 +185,7 @@ pub fn load_metrics_csv(csv_path: &Path) -> Option { se: se_v, pac: pac_v, lat: lat_v, + echt: echt_v, mood: mood_v, hr: hr_v, rmssd: rmssd_v, @@ -237,6 +241,7 @@ pub fn load_metrics_csv(csv_path: &Path) -> Option { sum.sample_entropy += se_v; sum.pac_theta_gamma += pac_v; sum.laterality_index += lat_v; + sum.echt += echt_v; sum.hr += hr_v; sum.rmssd += rmssd_v; sum.sdnn += sdnn_v; @@ -297,6 +302,7 @@ pub fn load_metrics_csv(csv_path: &Path) -> Option { sum.sample_entropy /= n; sum.pac_theta_gamma /= n; sum.laterality_index /= n; + sum.echt /= n; sum.hr /= n; sum.rmssd /= n; sum.sdnn /= n; @@ -432,6 +438,7 @@ fn load_metrics_from_parquet(path: &Path) -> Option { let gpu_v = f(x + 43); let gpu_r_v = f(x + 44); let gpu_t_v = f(x + 45); + let echt_v = f(x + 46); let mut sr = 0.0f64; let mut se2 = 0.0f64; @@ -482,6 +489,7 @@ fn load_metrics_from_parquet(path: &Path) -> Option { se: se_v, pac: pac_v, lat: lat_v, + echt: echt_v, mood: mood_v, hr: hr_v, rmssd: rmssd_v, @@ -537,6 +545,7 @@ fn load_metrics_from_parquet(path: &Path) -> Option { sum.sample_entropy += se_v; sum.pac_theta_gamma += pac_v; sum.laterality_index += lat_v; + sum.echt += echt_v; sum.hr += hr_v; sum.rmssd += rmssd_v; sum.sdnn += sdnn_v; @@ -597,6 +606,7 @@ fn load_metrics_from_parquet(path: &Path) -> Option { sum.sample_entropy /= n; sum.pac_theta_gamma /= n; sum.laterality_index /= n; + sum.echt /= n; sum.hr /= n; sum.rmssd /= n; sum.sdnn /= n; diff --git a/crates/skill-router/src/lib.rs b/crates/skill-router/src/lib.rs index f802f753..485ffbca 100644 --- a/crates/skill-router/src/lib.rs +++ b/crates/skill-router/src/lib.rs @@ -80,6 +80,7 @@ pub struct RoundedScores { pub sample_entropy: f32, pub pac_theta_gamma: f32, pub laterality_index: f32, + pub echt: f32, pub hr: f64, pub rmssd: f64, pub sdnn: f64, diff --git a/src/lib/charts/BandChart.svelte b/src/lib/charts/BandChart.svelte index 824bf145..788a4ae2 100644 --- a/src/lib/charts/BandChart.svelte +++ b/src/lib/charts/BandChart.svelte @@ -52,6 +52,7 @@ export interface BandSnapshot { sample_entropy?: number; pac_theta_gamma?: number; laterality_index?: number; + echt?: number; // PPG-derived hr?: number; rmssd?: number; diff --git a/src/lib/compare/compare-types.ts b/src/lib/compare/compare-types.ts index 6609c6f8..b1bfa5ae 100644 --- a/src/lib/compare/compare-types.ts +++ b/src/lib/compare/compare-types.ts @@ -92,6 +92,7 @@ export const advancedMetrics: MRow[] = [ { 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 }, @@ -126,6 +127,7 @@ export const HIGHER_IS_BETTER = new Set([ "pnn50", "rmssd", "sdnn", + "echt", ]); /** Metrics where lower B value = improvement. */ diff --git a/src/lib/dashboard/EegIndices.svelte b/src/lib/dashboard/EegIndices.svelte index 46ead50f..166c4a2c 100644 --- a/src/lib/dashboard/EegIndices.svelte +++ b/src/lib/dashboard/EegIndices.svelte @@ -32,6 +32,7 @@ interface Props { se: number; pac: number; lat: number; + echt: number; headache: number; migraine: number; /** @@ -65,6 +66,7 @@ let { se, pac, lat, + echt, headache, migraine, showMu = true, @@ -96,6 +98,7 @@ let { { k: "sampleEntropy", v: se.toFixed(3), c: '#6b7280' }, { k: "pacThetaGamma", v: pac.toFixed(3), c: pac>0.5?'var(--color-violet-500)':'#6b7280', bar: pac*100, bg:'bg-violet-500' }, { k: "lateralityIndex", v: (lat>=0?'+':'')+lat.toFixed(3), c: '#6b7280' }, + { k: "echt", v: echt.toFixed(3), c: echt>0.5?'#22c55e':'#6b7280', bar: echt*100, bg:'bg-emerald-500' }, { k: "headache", v: headache.toFixed(0), c: headache>60?'#f43f5e':headache>30?'#f59e0b':'#22c55e', bar: Math.min(100,headache), grad:'linear-gradient(90deg,#f87171,#ef4444)' }, { k: "migraine", v: migraine.toFixed(0), c: migraine>60?'#f43f5e':migraine>30?'#f59e0b':'#22c55e', bar: Math.min(100,migraine), grad:'linear-gradient(90deg,#fb7185,#f43f5e)' }, ] as item} diff --git a/src/lib/dashboard/SessionDetail.svelte b/src/lib/dashboard/SessionDetail.svelte index 741bc5ea..0a6e1fec 100644 --- a/src/lib/dashboard/SessionDetail.svelte +++ b/src/lib/dashboard/SessionDetail.svelte @@ -48,6 +48,7 @@ export interface SessionMetrics { sample_entropy: number; pac_theta_gamma: number; laterality_index: number; + echt: number; hr: number; rmssd: number; sdnn: number; @@ -102,6 +103,7 @@ export interface EpochRow { se: number; pac: number; lat: number; + echt: number; hr: number; rmssd: number; sdnn: number; @@ -285,6 +287,7 @@ export interface CsvMetricsResult { { l: t("sd.muSupp"), v: m.mu_suppression.toFixed(3), tip: t("tip.muSuppression") }, { l: t("sd.laterality"),v: m.laterality_index.toFixed(3), tip: t("tip.lateralityIndex") }, { l: t("sd.pac"), v: m.pac_theta_gamma.toFixed(3), tip: t("tip.pacThetaGamma") }, + { l: t("sd.echt"), v: m.echt.toFixed(3), tip: t("tip.echt") }, ] as item}
@@ -452,6 +455,7 @@ export interface CsvMetricsResult { { key: "mood", label: "Mood", color: C_MOOD, data: ts.map(r => r.mood) }, { key: "lat", label: "Laterality", color: C_DELTA, data: ts.map(r => r.lat) }, { key: "pac", label: "PAC θ-γ", color: C_BLINK, data: ts.map(r => r.pac) }, + { key: "echt", label: "ECHT", color: C_ALPHA, data: ts.map(r => r.echt) }, ]} />
{/if} diff --git a/src/lib/i18n/de/dashboard.ts b/src/lib/i18n/de/dashboard.ts index df840f6a..54f45638 100644 --- a/src/lib/i18n/de/dashboard.ts +++ b/src/lib/i18n/de/dashboard.ts @@ -54,6 +54,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "Stichpr.-Entropie", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "Lateralität", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG-Metriken", "dashboard.hr": "Herzfrequenz", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/de/history.ts b/src/lib/i18n/de/history.ts index 8e734df8..642a4654 100644 --- a/src/lib/i18n/de/history.ts +++ b/src/lib/i18n/de/history.ts @@ -66,6 +66,7 @@ const history: Record = { "compare.sampleEntropy": "Stichprobenentropie", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "Lateralitätsindex", + "compare.echt": "ECHT (Alpha-Rhythmizität)", "compare.hr": "Herzfrequenz", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", @@ -144,6 +145,7 @@ const history: Record = { "sd.muSupp": "Mu Unterdr.", "sd.laterality": "Lateralität", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth Akt.", "sd.hjorthMob": "Hjorth Mob.", "sd.hjorthCmpl": "Hjorth Kompl.", diff --git a/src/lib/i18n/de/ui.ts b/src/lib/i18n/de/ui.ts index 6d8d5d4b..15dd008a 100644 --- a/src/lib/i18n/de/ui.ts +++ b/src/lib/i18n/de/ui.ts @@ -48,6 +48,7 @@ const ui: Record = { "tip.sampleEntropy": "Stichproben-Entropie - Unregelmäßigkeit des Signals. Höher = unvorhersagbarer.", "tip.pacThetaGamma": "Phasen-Amplituden-Kopplung zwischen Theta und Gamma. Verknüpft mit Gedächtniskodierung.", "tip.lateralityIndex": "Links-Rechts-Leistungsasymmetrie. Positiv = rechtsdominant.", + "tip.echt": "Endpunkt-korrigierte Hilbert-Transformation — Alpha-Band-Rhythmizität (0–1). Hoch = starke, phasenstabile Alpha-Oszillation. [Schreglmann 2021]", "tip.hr": "Herzfrequenz aus PPG-Intervallen zwischen Herzschlägen.", "tip.rmssd": "Quadratischer Mittelwert aufeinanderfolgender Differenzen. Wichtige parasympathische HRV-Metrik.", "tip.sdnn": "Standardabweichung der Schlag-zu-Schlag-Intervalle. Spiegelt die gesamte HRV wider.", diff --git a/src/lib/i18n/en/dashboard.ts b/src/lib/i18n/en/dashboard.ts index f5594af7..874cc05f 100644 --- a/src/lib/i18n/en/dashboard.ts +++ b/src/lib/i18n/en/dashboard.ts @@ -57,6 +57,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "Sample Ent.", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "Laterality", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG Metrics", "dashboard.hr": "Heart Rate", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/en/history.ts b/src/lib/i18n/en/history.ts index e3c87f51..ad3d8adc 100644 --- a/src/lib/i18n/en/history.ts +++ b/src/lib/i18n/en/history.ts @@ -43,6 +43,7 @@ const history: Record = { "sd.muSupp": "Mu Supp.", "sd.laterality": "Laterality", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth Act.", "sd.hjorthMob": "Hjorth Mob.", "sd.hjorthCmpl": "Hjorth Cmpl.", @@ -203,6 +204,7 @@ const history: Record = { "compare.sampleEntropy": "Sample Entropy", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "Laterality Index", + "compare.echt": "ECHT (alpha rhythmicity)", "compare.hr": "Heart Rate", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", diff --git a/src/lib/i18n/en/ui.ts b/src/lib/i18n/en/ui.ts index 8e51b9fc..7d62b7cd 100644 --- a/src/lib/i18n/en/ui.ts +++ b/src/lib/i18n/en/ui.ts @@ -40,6 +40,7 @@ const ui: Record = { "tip.sampleEntropy": "Sample Entropy — irregularity of the signal. Higher = less predictable.", "tip.pacThetaGamma": "Phase-Amplitude Coupling between theta phase and gamma amplitude. Linked to memory encoding.", "tip.lateralityIndex": "Left–right power asymmetry across all bands. Positive = right-dominant.", + "tip.echt": "Endpoint-Corrected Hilbert Transform — alpha-band rhythmicity (0–1). High = strong, phase-stable alpha oscillation. [Schreglmann 2021]", "tip.hr": "Heart rate derived from PPG inter-beat intervals.", "tip.rmssd": "Root Mean Square of Successive Differences between heartbeats. Key parasympathetic HRV metric.", "tip.sdnn": "Standard deviation of beat-to-beat intervals. Reflects overall HRV.", diff --git a/src/lib/i18n/es/dashboard.ts b/src/lib/i18n/es/dashboard.ts index d50d2622..286951aa 100644 --- a/src/lib/i18n/es/dashboard.ts +++ b/src/lib/i18n/es/dashboard.ts @@ -57,6 +57,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "Muestra de Ent.", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "Lateralidad", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "Métricas de PPG", "dashboard.hr": "Frecuencia cardíaca", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/es/history.ts b/src/lib/i18n/es/history.ts index f2b8ea40..fd27f33a 100644 --- a/src/lib/i18n/es/history.ts +++ b/src/lib/i18n/es/history.ts @@ -43,6 +43,7 @@ const history: Record = { "sd.muSupp": "Sup. mu", "sd.laterality": "Lateralidad", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Ley Hjorth.", "sd.hjorthMob": "Mafia Hjorth.", "sd.hjorthCmpl": "Comp. Hjorth", @@ -204,6 +205,7 @@ const history: Record = { "compare.sampleEntropy": "Entropía de muestra", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "Índice de lateralidad", + "compare.echt": "ECHT (ritmicidad alfa)", "compare.hr": "Frecuencia cardíaca", "compare.rmssd": "RMSSD (VFC)", "compare.sdnn": "SDNN (HRV)", diff --git a/src/lib/i18n/es/ui.ts b/src/lib/i18n/es/ui.ts index 5230ca05..141f9d6d 100644 --- a/src/lib/i18n/es/ui.ts +++ b/src/lib/i18n/es/ui.ts @@ -50,6 +50,7 @@ const ui: Record = { "tip.pacThetaGamma": "Acoplamiento fase-amplitud entre fase theta y amplitud gamma. Vinculado a la codificación de la memoria.", "tip.lateralityIndex": "Asimetría de poder izquierda-derecha en todas las bandas. Positivo = derecha dominante.", + "tip.echt": "Transformada de Hilbert con corrección de extremos — ritmicidad de banda alfa (0–1). Alto = oscilación alfa intensa y estable en fase. [Schreglmann 2021]", "tip.hr": "Frecuencia cardíaca derivada de los intervalos entre latidos PPG.", "tip.rmssd": "Media cuadrática de diferencias sucesivas entre latidos del corazón. Métrica clave de VFC parasimpática.", diff --git a/src/lib/i18n/fr/dashboard.ts b/src/lib/i18n/fr/dashboard.ts index 0ae72e44..28d07043 100644 --- a/src/lib/i18n/fr/dashboard.ts +++ b/src/lib/i18n/fr/dashboard.ts @@ -54,6 +54,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "Ent. échantillon", "dashboard.pacThetaGamma": "CAP (θ-γ)", "dashboard.lateralityIndex": "Latéralité", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "Métriques PPG", "dashboard.hr": "Fréq. cardiaque", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/fr/history.ts b/src/lib/i18n/fr/history.ts index b7f78438..4bb59f33 100644 --- a/src/lib/i18n/fr/history.ts +++ b/src/lib/i18n/fr/history.ts @@ -66,6 +66,7 @@ const history: Record = { "compare.sampleEntropy": "Entropie d'échantillon", "compare.pacThetaGamma": "CAP (θ-γ)", "compare.lateralityIndex": "Indice de latéralité", + "compare.echt": "ECHT (rythmicité alpha)", "compare.hr": "Fréquence cardiaque", "compare.rmssd": "RMSSD (VFC)", "compare.sdnn": "SDNN (VFC)", @@ -144,6 +145,7 @@ const history: Record = { "sd.muSupp": "Suppr. Mu", "sd.laterality": "Latéralité", "sd.pac": "CPA θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth Act.", "sd.hjorthMob": "Hjorth Mob.", "sd.hjorthCmpl": "Hjorth Compl.", diff --git a/src/lib/i18n/fr/ui.ts b/src/lib/i18n/fr/ui.ts index 9fb2e825..02040486 100644 --- a/src/lib/i18n/fr/ui.ts +++ b/src/lib/i18n/fr/ui.ts @@ -49,6 +49,7 @@ const ui: Record = { "tip.sampleEntropy": "Entropie d'échantillon - irrégularité du signal. Plus élevé = moins prévisible.", "tip.pacThetaGamma": "Couplage phase-amplitude thêta-gamma. Lié à l'encodage mnésique.", "tip.lateralityIndex": "Asymétrie gauche-droite de puissance. Positif = dominance droite.", + "tip.echt": "Transformée de Hilbert à correction d'extrémité — rythmicité de la bande alpha (0–1). Élevé = oscillation alpha forte et phase stable. [Schreglmann 2021]", "tip.hr": "Fréquence cardiaque dérivée des intervalles inter-battements PPG.", "tip.rmssd": "Racine carrée des différences successives. Métrique parasympathique clé de la VFC.", "tip.sdnn": "Écart type des intervalles battement par battement. Reflète la VFC globale.", diff --git a/src/lib/i18n/he/dashboard.ts b/src/lib/i18n/he/dashboard.ts index e529660b..97b05980 100644 --- a/src/lib/i18n/he/dashboard.ts +++ b/src/lib/i18n/he/dashboard.ts @@ -28,6 +28,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "אנטר. דגימה", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "לטרליות", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "מדדי PPG", "dashboard.hr": "דופק", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/he/history.ts b/src/lib/i18n/he/history.ts index 6c28898f..55028ef6 100644 --- a/src/lib/i18n/he/history.ts +++ b/src/lib/i18n/he/history.ts @@ -64,6 +64,7 @@ const history: Record = { "compare.sampleEntropy": "אנטרופיית דגימה", "compare.pacThetaGamma": "צימוד פאזה-אמפליטודה (θ–γ)", "compare.lateralityIndex": "מדד לטרליות", + "compare.echt": "ECHT (קצביות אלפא)", "compare.hr": "דופק", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", @@ -142,6 +143,7 @@ const history: Record = { "sd.muSupp": "דיכוי Mu", "sd.laterality": "לטרליות", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth פעילות", "sd.hjorthMob": "Hjorth ניידות", "sd.hjorthCmpl": "Hjorth מורכבות", diff --git a/src/lib/i18n/he/ui.ts b/src/lib/i18n/he/ui.ts index 99ee85b3..de1f5347 100644 --- a/src/lib/i18n/he/ui.ts +++ b/src/lib/i18n/he/ui.ts @@ -58,6 +58,7 @@ const ui: Record = { "tip.sampleEntropy": "אנטרופיית מדגם — אי-סדירות האות. גבוה = פחות צפוי.", "tip.pacThetaGamma": "צימוד פאזה-אמפליטודה תטא–גמא. קשור לקידוד זיכרון.", "tip.lateralityIndex": "אסימטריית הספק שמאל-ימין. חיובי = דומיננטיות ימנית.", + "tip.echt": "טרנספורם הילברט מתוקן-קצוות — קצביות פס אלפא (0–1). גבוה = תנודת אלפא חזקה ויציבה בפאזה. [Schreglmann 2021]", "tip.hr": "קצב לב מרווחי PPG בין פעימות.", "tip.rmssd": "שורש ממוצע ריבועי של הפרשים עוקבים. מדד פאראסימפתטי מרכזי של HRV.", "tip.sdnn": "סטיית תקן של מרווחי פעימה-לפעימה. משקף HRV כולל.", diff --git a/src/lib/i18n/ja/dashboard.ts b/src/lib/i18n/ja/dashboard.ts index 64513eeb..87f0fec6 100644 --- a/src/lib/i18n/ja/dashboard.ts +++ b/src/lib/i18n/ja/dashboard.ts @@ -57,6 +57,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "サンプルエントロピー", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "左右差指標", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG指標", "dashboard.hr": "心拍数", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/ja/history.ts b/src/lib/i18n/ja/history.ts index 370fa7e2..82fd996a 100644 --- a/src/lib/i18n/ja/history.ts +++ b/src/lib/i18n/ja/history.ts @@ -43,6 +43,7 @@ const history: Record = { "sd.muSupp": "ミュー抑制", "sd.laterality": "左右差", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth 活動", "sd.hjorthMob": "Hjorth 移動性", "sd.hjorthCmpl": "Hjorth 複雑性", @@ -202,6 +203,7 @@ const history: Record = { "compare.sampleEntropy": "サンプルエントロピー", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "左右差指標", + "compare.echt": "ECHT(α律動性)", "compare.hr": "心拍数", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", diff --git a/src/lib/i18n/ja/ui.ts b/src/lib/i18n/ja/ui.ts index 5b403d5c..9c6422d6 100644 --- a/src/lib/i18n/ja/ui.ts +++ b/src/lib/i18n/ja/ui.ts @@ -36,6 +36,7 @@ const ui: Record = { "tip.sampleEntropy": "サンプルエントロピー — 信号の不規則性。高いほど予測不可能。", "tip.pacThetaGamma": "シータ位相とガンマ振幅の位相振幅結合。記憶のエンコーディングに関連。", "tip.lateralityIndex": "すべての帯域にわたる左右パワー非対称性。正 = 右優位。", + "tip.echt": "端点補正ヒルベルト変換 — α帯リズム性 (0–1)。高 = 強く位相が安定したα振動。[Schreglmann 2021]", "tip.hr": "PPG拍間間隔から導出された心拍数。", "tip.rmssd": "連続する心拍間隔の差の二乗平均平方根。主要な副交感HRV指標。", "tip.sdnn": "拍間間隔の標準偏差。全体的なHRVを反映。", diff --git a/src/lib/i18n/keys.ts b/src/lib/i18n/keys.ts index 4157f03b..92e815c5 100644 --- a/src/lib/i18n/keys.ts +++ b/src/lib/i18n/keys.ts @@ -413,6 +413,7 @@ export type TranslationKey = | "compare.diff" | "compare.drowsiness" | "compare.dtr" + | "compare.echt" | "compare.epochsA" | "compare.epochsB" | "compare.headPitch" @@ -543,6 +544,7 @@ export type TranslationKey = | "dashboard.disconnected" | "dashboard.drowsiness" | "dashboard.dtr" + | "dashboard.echt" | "dashboard.eegChannels" | "dashboard.eegWaveforms" | "dashboard.engagement" @@ -1891,6 +1893,7 @@ export type TranslationKey = | "sd.faa" | "sd.gamma" | "sd.higuchiFd" + | "sd.echt" | "sd.hjorthAct" | "sd.hjorthCmpl" | "sd.hjorthMob" @@ -2543,6 +2546,7 @@ export type TranslationKey = | "tip.gamma" | "tip.headache" | "tip.higuchiFd" + | "tip.echt" | "tip.hjorthActivity" | "tip.hjorthComplexity" | "tip.hjorthMobility" diff --git a/src/lib/i18n/ko/dashboard.ts b/src/lib/i18n/ko/dashboard.ts index d1f37f6a..42ec9d06 100644 --- a/src/lib/i18n/ko/dashboard.ts +++ b/src/lib/i18n/ko/dashboard.ts @@ -57,6 +57,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "표본 엔트로피", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "편측성", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG 지표", "dashboard.hr": "심박수", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/ko/history.ts b/src/lib/i18n/ko/history.ts index 469e4f07..ecaae70e 100644 --- a/src/lib/i18n/ko/history.ts +++ b/src/lib/i18n/ko/history.ts @@ -43,6 +43,7 @@ const history: Record = { "sd.muSupp": "Mu 억제", "sd.laterality": "편측성", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth 활동", "sd.hjorthMob": "Hjorth 이동성", "sd.hjorthCmpl": "Hjorth 복잡도", @@ -202,6 +203,7 @@ const history: Record = { "compare.sampleEntropy": "표본 엔트로피", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "편측성 지수", + "compare.echt": "ECHT (알파 율동성)", "compare.hr": "심박수", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", diff --git a/src/lib/i18n/ko/ui.ts b/src/lib/i18n/ko/ui.ts index f047be45..83e43246 100644 --- a/src/lib/i18n/ko/ui.ts +++ b/src/lib/i18n/ko/ui.ts @@ -36,6 +36,7 @@ const ui: Record = { "tip.sampleEntropy": "표본 엔트로피 — 신호의 불규칙성. 높을수록 예측 불가능.", "tip.pacThetaGamma": "세타 위상과 감마 진폭 간 위상-진폭 커플링. 기억 부호화와 연관됩니다.", "tip.lateralityIndex": "모든 대역에 걸친 좌우 파워 비대칭. 양수 = 우측 우세.", + "tip.echt": "끝점 보정 힐베르트 변환 — 알파 대역 리듬성 (0–1). 높음 = 강하고 위상이 안정된 알파 진동. [Schreglmann 2021]", "tip.hr": "PPG 심박 간격에서 유래한 심박수.", "tip.rmssd": "연속 심박 간격 차이의 제곱평균제곱근. 핵심 부교감 HRV 지표.", "tip.sdnn": "박동 간 간격의 표준편차. 전체 HRV를 반영합니다.", diff --git a/src/lib/i18n/uk/dashboard.ts b/src/lib/i18n/uk/dashboard.ts index d66d016d..00efbe8c 100644 --- a/src/lib/i18n/uk/dashboard.ts +++ b/src/lib/i18n/uk/dashboard.ts @@ -54,6 +54,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "Вибірк. ентропія", "dashboard.pacThetaGamma": "ФАЗ (θ–γ)", "dashboard.lateralityIndex": "Латеральність", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG метрики", "dashboard.hr": "Пульс", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/uk/history.ts b/src/lib/i18n/uk/history.ts index e7dfc567..9fa288b0 100644 --- a/src/lib/i18n/uk/history.ts +++ b/src/lib/i18n/uk/history.ts @@ -66,6 +66,7 @@ const history: Record = { "compare.sampleEntropy": "Вибіркова ентропія", "compare.pacThetaGamma": "ФАЗ (θ–γ)", "compare.lateralityIndex": "Індекс латеральності", + "compare.echt": "ECHT (ритмічність альфа)", "compare.hr": "Пульс", "compare.rmssd": "RMSSD (ВСР)", "compare.sdnn": "SDNN (ВСР)", @@ -144,6 +145,7 @@ const history: Record = { "sd.muSupp": "Придуш. Мю", "sd.laterality": "Латеральність", "sd.pac": "ФАЗ θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth Акт.", "sd.hjorthMob": "Hjorth Моб.", "sd.hjorthCmpl": "Hjorth Скл.", diff --git a/src/lib/i18n/uk/ui.ts b/src/lib/i18n/uk/ui.ts index e512a08f..31715842 100644 --- a/src/lib/i18n/uk/ui.ts +++ b/src/lib/i18n/uk/ui.ts @@ -44,6 +44,7 @@ const ui: Record = { "tip.sampleEntropy": "Вибіркова ентропія — нерегулярність сигналу. Вище = менш передбачуваний.", "tip.pacThetaGamma": "Фазово-амплітудне зв'язування тета–гамма. Пов'язане з кодуванням пам'яті.", "tip.lateralityIndex": "Ліво-права асиметрія потужності. Позитивне = правобічна домінантність.", + "tip.echt": "Гільбертове перетворення з кінцевою корекцією — ритмічність альфа-діапазону (0–1). Високе = сильні фазово-стабільні альфа-коливання. [Schreglmann 2021]", "tip.hr": "Частота серцевих скорочень з міжудкових інтервалів PPG.", "tip.rmssd": "Середньоквадратичне послідовних різниць. Ключова парасимпатична метрика ВСР.", "tip.sdnn": "Стандартне відхилення інтервалів між ударами. Відображає загальну ВСР.", diff --git a/src/lib/i18n/zh/dashboard.ts b/src/lib/i18n/zh/dashboard.ts index 7ec5626f..f539eb46 100644 --- a/src/lib/i18n/zh/dashboard.ts +++ b/src/lib/i18n/zh/dashboard.ts @@ -57,6 +57,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "样本熵", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "偏侧性", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG 指标", "dashboard.hr": "心率", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/zh/history.ts b/src/lib/i18n/zh/history.ts index 2f064fb9..86aa6e8b 100644 --- a/src/lib/i18n/zh/history.ts +++ b/src/lib/i18n/zh/history.ts @@ -43,6 +43,7 @@ const history: Record = { "sd.muSupp": "Mu 抑制", "sd.laterality": "偏侧性", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth 活动度", "sd.hjorthMob": "Hjorth 移动度", "sd.hjorthCmpl": "Hjorth 复杂度", @@ -201,6 +202,7 @@ const history: Record = { "compare.sampleEntropy": "样本熵", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "偏侧性指数", + "compare.echt": "ECHT(α 节律性)", "compare.hr": "心率", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", diff --git a/src/lib/i18n/zh/ui.ts b/src/lib/i18n/zh/ui.ts index 6dc5bc93..0f1ed6a4 100644 --- a/src/lib/i18n/zh/ui.ts +++ b/src/lib/i18n/zh/ui.ts @@ -36,6 +36,7 @@ const ui: Record = { "tip.sampleEntropy": "样本熵 — 信号的不规则性。数值越高 = 越不可预测。", "tip.pacThetaGamma": "Theta 相位与 Gamma 幅度之间的相幅耦合。与记忆编码相关。", "tip.lateralityIndex": "所有频段的左右功率不对称性。正值 = 右侧主导。", + "tip.echt": "端点校正希尔伯特变换 — α 频段节律性 (0–1)。高 = α 振荡强且相位稳定。[Schreglmann 2021]", "tip.hr": "由 PPG 搏动间期推导的心率。", "tip.rmssd": "连续心搏差异的均方根。关键副交感 HRV 指标。", "tip.sdnn": "逐搏间期的标准差。反映整体 HRV。", diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b27a5506..1daa8d67 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -329,6 +329,7 @@ let dfaScore = $state(0); // DFA Exponent let seScore = $state(0); // Sample Entropy let pacScore = $state(0); // PAC (θ–γ) let latScore = $state(0); // Laterality Index +let echtScore = $state(0); // ECHT alpha rhythmicity let hrScore = $state(0); // Heart Rate (bpm) let rmssdScore = $state(0); // RMSSD (ms) let sdnnScore = $state(0); // SDNN (ms) @@ -427,6 +428,7 @@ function updateScores(snap: BandSnapshot) { if (snap.sample_entropy !== undefined) seScore = seScore + SCORE_TAU * (snap.sample_entropy - seScore); if (snap.pac_theta_gamma !== undefined) pacScore = pacScore + SCORE_TAU * (snap.pac_theta_gamma - pacScore); if (snap.laterality_index !== undefined) latScore = latScore + SCORE_TAU * (snap.laterality_index - latScore); + if (snap.echt !== undefined) echtScore = echtScore + SCORE_TAU * (snap.echt - echtScore); // PPG-derived if (snap.hr !== undefined && snap.hr > 0) hrScore = hrScore + SCORE_TAU * (snap.hr - hrScore); if (snap.rmssd !== undefined && snap.rmssd > 0) rmssdScore = rmssdScore + SCORE_TAU * (snap.rmssd - rmssdScore); @@ -2292,6 +2294,7 @@ useWindowTitle("window.title.main"); mood={moodScore} bps={bpsScore} snr={snrScore} coherence={coherenceScore} mu={muScore} tbr={tbrScore} sef95={sef95Score} sc={scScore} ha={haScore} hm={hmScore} hc={hcScore} pe={peScore} hfd={hfdScore} dfa={dfaScore} se={seScore} pac={pacScore} lat={latScore} + echt={echtScore} headache={headacheScore} migraine={migraineScore} showMu={status.has_central_electrodes} /> diff --git a/src/tests/virtual-device-e2e.spec.ts b/src/tests/virtual-device-e2e.spec.ts index 6f6c9471..b81d37cf 100644 --- a/src/tests/virtual-device-e2e.spec.ts +++ b/src/tests/virtual-device-e2e.spec.ts @@ -200,6 +200,7 @@ const SYNTH_BANDS: Record = { sample_entropy: 1.4, pac_theta_gamma: 0.08, laterality_index: 0.03, + echt: 0.5, hr: 68, rmssd: 42, sdnn: 55, From 2bfa36f1d5e20ca7b35e71a7e99a421d0a2c3913 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Tue, 5 May 2026 00:32:02 -0400 Subject: [PATCH 36/98] echt --- changes/unreleased/feat-echt-metric.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changes/unreleased/feat-echt-metric.md diff --git a/changes/unreleased/feat-echt-metric.md b/changes/unreleased/feat-echt-metric.md new file mode 100644 index 00000000..b4f9d061 --- /dev/null +++ b/changes/unreleased/feat-echt-metric.md @@ -0,0 +1,7 @@ +### Features + +- **ECHT (Endpoint-Corrected Hilbert Transform)**: new EEG metric measuring alpha-band rhythmicity (0–1) via a causal complex-Morlet kernel. Phase estimates remain valid at the buffer edge, where FFT-based Hilbert breaks down. Surfaced in the live dashboard, session detail view, comparison view, recordings (CSV/Parquet), and history aggregates. Reference: Schreglmann et al., *Nat. Commun.* 12:363 (2021), [doi:10.1038/s41467-020-20581-7](https://doi.org/10.1038/s41467-020-20581-7). + +### i18n + +- **ECHT translations**: added `sd.echt`, `compare.echt`, `dashboard.echt`, and `tip.echt` for all 9 supported locales (en, de, es, fr, he, ja, ko, uk, zh). From 782d19267f64733e355d0f2d0dcce23161f24877 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Tue, 5 May 2026 01:17:45 -0400 Subject: [PATCH 37/98] added drop tests --- crates/skill-daemon/src/session/pipeline.rs | 258 +++++++++++++++++++- crates/skill-daemon/src/session/runner.rs | 56 +++-- crates/skill-daemon/src/session/shared.rs | 39 +++ crates/skill-history/src/lib.rs | 244 +++++++++++++++++- crates/skill-history/src/local_days.rs | 6 +- 5 files changed, 575 insertions(+), 28 deletions(-) diff --git a/crates/skill-daemon/src/session/pipeline.rs b/crates/skill-daemon/src/session/pipeline.rs index a7c8424c..24f5509a 100644 --- a/crates/skill-daemon/src/session/pipeline.rs +++ b/crates/skill-daemon/src/session/pipeline.rs @@ -15,7 +15,7 @@ use skill_settings::HookRule; use tokio::sync::broadcast; use tracing::info; -use super::shared::{enrich_band_snapshot, unix_secs, utc_date_dir, write_session_meta}; +use super::shared::{enrich_band_snapshot, unix_secs, utc_date_dir, write_session_meta, write_session_meta_partial}; use crate::embed::{EmbedWorkerHandle, EpochAccumulator}; // ── Epoch metrics store ────────────────────────────────────────────────────── @@ -270,6 +270,28 @@ impl Pipeline { self.quality.all_qualities() } + /// Write an in-progress sidecar JSON next to the CSV. + /// + /// Called immediately after `open` (once device identity has been + /// threaded in by the runner) and after every `roll`. The next call + /// to `finalize` overwrites it with the complete sidecar. Purpose: + /// a daemon killed mid-chunk leaves a usable sidecar so + /// `list_sessions_for_day` doesn't fall back to CSV-header sniffing. + pub(crate) fn write_partial_sidecar(&self) { + write_session_meta_partial( + &self.csv_path, + &self.device_name, + &self.channel_names, + self.sample_rate, + self.start_utc, + &crate::session::shared::SessionDeviceId { + firmware_version: self.firmware_version.as_deref(), + serial_number: self.serial_number.as_deref(), + }, + &self.device_kind, + ); + } + pub(crate) fn finalize(&mut self) { self.writer.flush(); self.writer.close(); @@ -303,7 +325,15 @@ impl Pipeline { /// know about rollover. pub(crate) fn roll(&mut self, skill_dir: &Path) -> anyhow::Result<()> { // 1. Finalise the current chunk (writer flush+close, sidecar JSON). + let just_closed = self.csv_path.clone(); self.finalize(); + // Pre-warm the metrics cache for the just-closed chunk in a + // background thread. With hourly rollover, an overnight 8h + // recording produces ~480 chunks; without pre-warming, the first + // history-page load cold-builds all caches synchronously. + std::thread::spawn(move || { + let _ = skill_history::load_csv_metrics_cached(&just_closed); + }); // 2. Compute a new csv_path. unix_secs() granularity is 1s; if a // rollover lands inside the same second as the previous start, @@ -328,6 +358,10 @@ impl Pipeline { self.total_samples = 0; self.flush_counter = 0; + // 5. Drop a partial sidecar for the new chunk so a crash before + // the next finalize still leaves a readable session entry. + self.write_partial_sidecar(); + info!(path = %self.csv_path.display(), "session rolled"); Ok(()) } @@ -564,6 +598,228 @@ mod tests { assert_eq!(m2["device_name"], "RollDevice"); } + /// Partial sidecar must be writable before finalize and must contain + /// the device/channel/rate fields needed by `list_sessions_for_day`. + #[test] + fn write_partial_sidecar_creates_in_progress_json() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 4, + 256.0, + vec!["TP9".into(), "AF7".into(), "AF8".into(), "TP10".into()], + "PartialDevice".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + + // Simulate the runner enriching device identity, then write partial. + pipe.firmware_version = Some("fw-2.1.3".into()); + pipe.serial_number = Some("SN-XYZ".into()); + pipe.device_kind = "muse".into(); + pipe.write_partial_sidecar(); + + let sidecar = pipe.csv_path.with_extension("json"); + assert!(sidecar.exists(), "partial sidecar must exist before finalize"); + let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap(); + + assert_eq!(v["device_name"], "PartialDevice"); + assert_eq!(v["sample_rate_hz"], 256.0); + assert_eq!(v["device_kind"], "muse"); + assert_eq!(v["firmware_version"], "fw-2.1.3"); + assert_eq!(v["serial_number"], "SN-XYZ"); + assert_eq!(v["channel_count"], 4); + assert_eq!(v["channel_names"][0], "TP9"); + assert_eq!(v["in_progress"], true); + assert_eq!(v["total_samples"], 0); + assert!(v.get("session_start_utc").and_then(|x| x.as_u64()).is_some()); + + // Now push samples and finalize: full sidecar must overwrite + // (in_progress flag dropped, total_samples populated). + for i in 0..32 { + pipe.push_eeg(&[1.0, 2.0, 3.0, 4.0], i as f64 / 256.0); + } + pipe.finalize(); + + let v2: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap(); + assert_eq!(v2["device_name"], "PartialDevice"); + assert_eq!(v2["total_samples"], 32); + assert!(v2.get("in_progress").is_none(), "in_progress flag dropped on finalize"); + assert!(v2.get("session_end_utc").and_then(|x| x.as_u64()).is_some()); + } + + /// After Pipeline::roll, the new chunk must have a partial sidecar + /// before any samples are written, just like the initial open. + #[test] + fn pipeline_roll_writes_partial_sidecar_for_new_chunk() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 2, + 128.0, + vec!["Ch1".into(), "Ch2".into()], + "RollPartial".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + pipe.device_kind = "test".into(); + pipe.write_partial_sidecar(); + + // Roll without writing any samples to chunk 2. + pipe.roll(dir.path()).unwrap(); + + // Sidecar for chunk 2 must already exist from the partial write. + let chunk2_sidecar = pipe.csv_path.with_extension("json"); + assert!(chunk2_sidecar.exists(), "partial sidecar for new chunk must exist"); + let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&chunk2_sidecar).unwrap()).unwrap(); + assert_eq!(v["in_progress"], true); + assert_eq!(v["device_name"], "RollPartial"); + assert_eq!(v["device_kind"], "test"); + assert_eq!(v["total_samples"], 0); + } + + /// `Pipeline::roll` must trigger a background pre-warm of the + /// just-closed chunk's metrics cache so the history page doesn't pay + /// the cold-build cost on first open. + #[test] + fn pipeline_roll_prewarms_metrics_cache() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 4, + 256.0, + vec!["TP9".into(), "AF7".into(), "AF8".into(), "TP10".into()], + "PrewarmDevice".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + pipe.device_kind = "muse".into(); + + // Write enough samples to produce at least a few metrics rows. + for i in 0..1500 { + pipe.push_eeg(&[1.0, 2.0, 3.0, 4.0], i as f64 / 256.0); + } + let chunk1 = pipe.csv_path.clone(); + let metrics_path = chunk1.with_file_name(format!( + "{}_metrics.csv", + chunk1.file_stem().and_then(|s| s.to_str()).unwrap_or("") + )); + let cache_path = chunk1.with_file_name(format!( + "{}_metrics_cache.json", + chunk1.file_stem().and_then(|s| s.to_str()).unwrap_or("") + )); + + pipe.roll(dir.path()).unwrap(); + + // Roll spawns a background thread; poll briefly for the cache + // file to appear. Cap at 2s to keep the test snappy if the + // pre-warm is somehow disabled. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut appeared = false; + while std::time::Instant::now() < deadline { + if cache_path.exists() { + appeared = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + + assert!(metrics_path.exists(), "metrics CSV must exist after roll"); + assert!( + appeared, + "metrics_cache.json must be pre-warmed within 2s of roll: {cache_path:?}" + ); + // Cache content must be valid JSON. + let cache_str = std::fs::read_to_string(&cache_path).unwrap(); + let _: serde_json::Value = serde_json::from_str(&cache_str).expect("cache is valid JSON"); + + pipe.finalize(); + } + + /// Empirical: every sample pushed before `roll` must end up as a row + /// in the closed chunk's CSV. If the on-disk row count is short of + /// what we pushed, that pinpoints the loss as CSV-writer residue at + /// the rollover boundary (the 0.15% the 1-hour test observed). + #[test] + fn pipeline_roll_no_sample_loss_on_csv_boundary() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 4, + 256.0, + vec!["TP9".into(), "AF7".into(), "AF8".into(), "TP10".into()], + "LossDevice".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + pipe.device_kind = "muse".into(); + + // Push exactly 1000 frames (1000 samples × 4 channels) to chunk 1. + const N: usize = 1000; + for i in 0..N { + pipe.push_eeg(&[1.0, 2.0, 3.0, 4.0], i as f64 / 256.0); + } + let chunk1_path = pipe.csv_path.clone(); + let pushed_chunk1 = pipe.total_samples; + assert_eq!(pushed_chunk1, N as u64, "counter must match pushes"); + + // Roll and finalize the new chunk to flush both files cleanly. + pipe.roll(dir.path()).unwrap(); + for i in 0..50 { + pipe.push_eeg(&[5.0, 6.0, 7.0, 8.0], i as f64 / 256.0); + } + pipe.finalize(); + + // Count actual data rows on disk (subtract the header). + let content = std::fs::read_to_string(&chunk1_path).unwrap(); + let data_rows = content.lines().count().saturating_sub(1); + + assert_eq!( + data_rows, N, + "chunk1 CSV must contain every pushed sample (residue-free roll)" + ); + } + /// Same-second rollover must still produce a unique filename. #[test] fn pipeline_roll_handles_subsecond_collision() { diff --git a/crates/skill-daemon/src/session/runner.rs b/crates/skill-daemon/src/session/runner.rs index 140f6e1e..9e70a0d1 100644 --- a/crates/skill-daemon/src/session/runner.rs +++ b/crates/skill-daemon/src/session/runner.rs @@ -152,6 +152,10 @@ pub(crate) async fn run_adapter_session( p.firmware_version = info.firmware_version.clone(); p.device_kind = device_kind.to_string(); p.fnirs_channel_names = current_desc.fnirs_channel_names.clone(); + // Drop a partial sidecar before any samples + // flow so a crash here leaves a readable + // session entry for list_sessions_for_day. + p.write_partial_sidecar(); if let Ok(mut s) = state.status.lock() { s.csv_path = Some(p.csv_path.display().to_string()); } @@ -2025,37 +2029,39 @@ mod tests { let day = day_dir.file_name().unwrap().to_str().unwrap().to_string(); // 1. History listing — used by /v1/history/sessions and the frontend - // history page. Must return one entry per chunk with sane fields. + // history page. Same-device adjacent chunks are now collapsed + // into one logical entry whose `chunk_count` reflects the roll + // count. With this test pushing N chunks of "Muse-Roll", expect + // a single merged entry covering all of them. let entries = skill_history::list_sessions_for_day(&day, dir.path(), None); assert_eq!( entries.len(), - chunks.len(), - "list_sessions_for_day count must match chunk count" + 1, + "{} same-device chunks must collapse into 1 logical entry", + chunks.len() ); - for e in &entries { - assert_eq!(e.device_name.as_deref(), Some("Muse-Roll")); - assert!( - std::path::Path::new(&e.csv_path).exists(), - "csv_path from sidecar must resolve: {}", - e.csv_path - ); - assert!(e.session_start_utc.is_some(), "session_start_utc populated"); - assert!( - e.firmware_version.as_deref() == Some("1.0.0"), - "firmware_version threaded through to entry" - ); - } - // Entries are sorted by session_start_utc ascending or descending — - // enforce strict monotonicity so the UI shows a consistent order - // across rollover boundaries. - let starts: Vec = entries.iter().filter_map(|e| e.session_start_utc).collect(); - assert_eq!(starts.len(), entries.len(), "every entry has a start time"); - let monotonic_inc = starts.windows(2).all(|w| w[0] <= w[1]); - let monotonic_dec = starts.windows(2).all(|w| w[0] >= w[1]); + let merged = &entries[0]; + assert_eq!( + u32::try_from(chunks.len()).unwrap(), + merged.chunk_count, + "merged entry's chunk_count must match the on-disk chunk count" + ); + assert_eq!(merged.device_name.as_deref(), Some("Muse-Roll")); + assert!( + std::path::Path::new(&merged.csv_path).exists(), + "canonical csv_path resolves: {}", + merged.csv_path + ); + assert!(merged.session_start_utc.is_some(), "session_start_utc populated"); assert!( - monotonic_inc || monotonic_dec, - "session list must be monotonically ordered: {starts:?}" + merged.firmware_version.as_deref() == Some("1.0.0"), + "firmware_version threaded through to merged entry" ); + let chunk_paths = merged.chunks.as_ref().expect("chunks list present on merged entry"); + assert_eq!(chunk_paths.len(), chunks.len(), "every chunk path preserved"); + for p in chunk_paths { + assert!(std::path::Path::new(p).exists(), "every chunk path resolves: {p}"); + } // 2. Per-day SQLite (search index) is created for the day, not per // session — so embeddings written by a chunk land in the same diff --git a/crates/skill-daemon/src/session/shared.rs b/crates/skill-daemon/src/session/shared.rs index b63ef9b6..280da1e9 100644 --- a/crates/skill-daemon/src/session/shared.rs +++ b/crates/skill-daemon/src/session/shared.rs @@ -182,3 +182,42 @@ pub fn write_session_meta_full( let _ = std::fs::write(csv_path.with_extension("json"), json); } } + +/// Write a minimal in-progress sidecar JSON immediately after opening a +/// recording, before any samples are flushed. Crash-resilience: a daemon +/// killed mid-chunk leaves a sidecar with the known device/channel/rate +/// fields, so `list_sessions_for_day` doesn't have to fall back to +/// CSV-header sniffing for partial recordings. +/// +/// Carries an `in_progress: true` marker; the full writer overwrites this +/// file on `finalize()` and that flag is dropped. +pub fn write_session_meta_partial( + csv_path: &Path, + device_name: &str, + channel_names: &[String], + sample_rate: f64, + start_utc: u64, + device_id: &SessionDeviceId<'_>, + device_kind: &str, +) { + let meta = serde_json::json!({ + "csv_file": csv_path.file_name().and_then(|n| n.to_str()).unwrap_or(""), + "session_start_utc": start_utc, + "total_samples": 0, + "sample_rate_hz": sample_rate, + "device_name": device_name, + "device_kind": device_kind, + "channel_names": channel_names, + "channel_count": channel_names.len(), + "firmware_version": device_id.firmware_version, + "serial_number": device_id.serial_number, + "daemon": true, + "in_progress": true, + "platform": std::env::consts::OS, + "arch": std::env::consts::ARCH, + }); + + if let Ok(json) = serde_json::to_string_pretty(&meta) { + let _ = std::fs::write(csv_path.with_extension("json"), json); + } +} diff --git a/crates/skill-history/src/lib.rs b/crates/skill-history/src/lib.rs index 8f281ca6..3f603bc3 100644 --- a/crates/skill-history/src/lib.rs +++ b/crates/skill-history/src/lib.rs @@ -126,6 +126,20 @@ pub struct SessionEntry { /// Average signal-to-noise ratio (dB) for the session. /// `None` for legacy sessions recorded before SNR tracking. pub avg_snr_db: Option, + /// Number of underlying rollover chunks merged into this entry. `1` + /// for ordinary single-chunk sessions; `>1` when adjacent same-device + /// chunks have been collapsed into one logical session for the UI. + #[serde(default = "default_chunk_count")] + pub chunk_count: u32, + /// CSV paths of every chunk in this logical session, oldest first. + /// `None` for non-collapsed entries (the canonical `csv_path` is the + /// only chunk). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chunks: Option>, +} + +fn default_chunk_count() -> u32 { + 1 } // ── Typed session JSON sidecar (replaces serde_json::Value for speed) ───────── @@ -574,6 +588,8 @@ pub fn list_sessions_for_day( labels: vec![], file_size_bytes: csv_size, avg_snr_db: meta.avg_snr_db, + chunk_count: 1, + chunks: None, }, start, end, @@ -618,6 +634,8 @@ pub fn list_sessions_for_day( labels: vec![], file_size_bytes: csv_size, avg_snr_db: None, // no sidecar available + chunk_count: 1, + chunks: None, }, ts, end_ts, @@ -642,7 +660,100 @@ pub fn list_sessions_for_day( let mut sessions: Vec = raw.into_iter().map(|(s, _, _)| s).collect(); sessions.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); - sessions + collapse_adjacent_chunks(sessions) +} + +/// Maximum gap (seconds) between two same-device chunks for them to be +/// considered the "same logical session" and merged into one entry. +/// Rollover boundaries normally produce 0–1s gaps; tolerate up to 5s for +/// LSL/BLE jitter or a brief reconnect. +const ROLLOVER_GAP_TOLERANCE_S: u64 = 5; + +/// Collapse adjacent same-device chunks into single logical entries. +/// +/// Rollover (`session_rollover_minutes`) splits long recordings into many +/// chunk files. For UI listing, runs of chunks with the same `device_name` +/// and ≤ `ROLLOVER_GAP_TOLERANCE_S` seconds between adjacent end → start +/// are merged into one entry that aggregates `total_samples`, +/// `session_duration_s`, `file_size_bytes`, and labels. The newest +/// chunk's `csv_path` is kept as the canonical reference; every chunk's +/// path is preserved in the new `chunks` field for drill-down. +/// +/// Crate-internal alias so sibling modules (`local_days`) can re-collapse +/// after dir-merging. Kept thin and named to match its private twin. +pub(crate) fn collapse_adjacent_chunks_pub(sorted_desc: Vec) -> Vec { + collapse_adjacent_chunks(sorted_desc) +} + +/// Input must be sorted by `session_start_utc` descending. +fn collapse_adjacent_chunks(sorted_desc: Vec) -> Vec { + if sorted_desc.is_empty() { + return sorted_desc; + } + + let mut out: Vec = Vec::with_capacity(sorted_desc.len()); + for s in sorted_desc { + let Some(head) = out.last_mut() else { + out.push(s); + continue; + }; + // `s` is older than head (input sorted DESC). Adjacent if `s.end` + // is within ROLLOVER_GAP_TOLERANCE_S of `head.start`, AND device + // names match (skip merging across device swaps). + let same_device = match (&head.device_name, &s.device_name) { + (Some(a), Some(b)) => a == b, + _ => false, + }; + let adjacent = match (head.session_start_utc, s.session_end_utc) { + (Some(hs), Some(se)) => hs.saturating_sub(se) <= ROLLOVER_GAP_TOLERANCE_S && hs >= se, + _ => false, + }; + if !same_device || !adjacent { + out.push(s); + continue; + } + // Merge `s` into `head` (head is the newer one; absorb older). + head.session_start_utc = s.session_start_utc.or(head.session_start_utc); + if let (Some(hs), Some(he)) = (head.session_start_utc, head.session_end_utc) { + head.session_duration_s = Some(he.saturating_sub(hs)); + } + head.total_samples = match (head.total_samples, s.total_samples) { + (Some(a), Some(b)) => Some(a + b), + (Some(a), None) | (None, Some(a)) => Some(a), + (None, None) => None, + }; + head.file_size_bytes = head.file_size_bytes.saturating_add(s.file_size_bytes); + head.chunk_count = head.chunk_count.saturating_add(1); + // Inherit identity / hardware fields from the absorbed chunk if + // the head is missing them (e.g. an in-progress chunk with a + // partial sidecar). + head.firmware_version = head.firmware_version.clone().or(s.firmware_version); + head.serial_number = head.serial_number.clone().or(s.serial_number); + head.mac_address = head.mac_address.clone().or(s.mac_address); + head.hardware_version = head.hardware_version.clone().or(s.hardware_version); + head.sample_rate_hz = head.sample_rate_hz.or(s.sample_rate_hz); + // Keep all chunk paths; the canonical csv_path stays as head's. + let chunks = head.chunks.get_or_insert_with(|| vec![head.csv_path.clone()]); + chunks.push(s.csv_path); + // Concatenate labels (oldest first → newest last after the rev). + head.labels.extend(s.labels); + // avg_snr_db: take the simple average of available values for now + // (a sample-weighted average would require keeping per-chunk + // counts; not worth the extra plumbing for a UI summary field). + head.avg_snr_db = match (head.avg_snr_db, s.avg_snr_db) { + (Some(a), Some(b)) => Some((a + b) / 2.0), + (Some(a), None) | (None, Some(a)) => Some(a), + (None, None) => None, + }; + } + // Each merged entry's `chunks` was built head-then-older; reverse so + // it reads oldest → newest, matching frontend expectations. + for entry in &mut out { + if let Some(c) = entry.chunks.as_mut() { + c.reverse(); + } + } + out } /// Compute average SNR (dB) from the embeddings SQLite for sessions that @@ -1433,4 +1544,135 @@ mod session_listing_tests { assert!(!metrics.exists()); assert!(!ppg.exists()); } + + // ── collapse_adjacent_chunks ────────────────────────────────────────── + + /// Helper: build a SessionEntry with the fields the collapser actually + /// reads, defaulting the rest. Input `(start, end, samples, dev)`. + fn mk(start: u64, end: u64, samples: u64, device: &str, path: &str) -> super::SessionEntry { + super::SessionEntry { + csv_file: format!("{path}.csv"), + csv_path: path.to_string(), + session_start_utc: Some(start), + session_end_utc: Some(end), + session_duration_s: Some(end - start), + device_name: Some(device.to_string()), + device_id: None, + serial_number: None, + mac_address: None, + firmware_version: None, + hardware_version: None, + headset_preset: None, + battery_pct: None, + total_samples: Some(samples), + sample_rate_hz: Some(256), + labels: vec![], + file_size_bytes: 100, + avg_snr_db: Some(15.0), + chunk_count: 1, + chunks: None, + } + } + + #[test] + fn collapse_merges_60_adjacent_chunks_into_one_entry() { + // Mirror the 1-hour test: 60 chunks back-to-back, same device. + let mut chunks: Vec = (0..60) + .map(|i| { + let start = 1_777_900_000 + i as u64 * 60; + mk(start, start + 60, 15360, "Muse-Roll", &format!("p{i}")) + }) + .collect(); + chunks.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let merged = super::collapse_adjacent_chunks(chunks); + assert_eq!(merged.len(), 1, "60 adjacent chunks must collapse to one"); + let m = &merged[0]; + assert_eq!(m.chunk_count, 60); + assert_eq!(m.total_samples, Some(15360 * 60)); + assert_eq!(m.session_duration_s, Some(60 * 60)); + assert_eq!(m.session_start_utc, Some(1_777_900_000)); + assert_eq!(m.session_end_utc, Some(1_777_900_000 + 60 * 60)); + let chunk_paths = m.chunks.as_ref().expect("chunks list populated"); + assert_eq!(chunk_paths.len(), 60); + assert_eq!(chunk_paths.first().unwrap(), "p0", "oldest first"); + assert_eq!(chunk_paths.last().unwrap(), "p59", "newest last"); + } + + #[test] + fn collapse_keeps_non_adjacent_separate() { + // Two clusters separated by a 5-minute gap. + let mut entries = vec![ + mk(1000, 1060, 100, "Muse", "a1"), + mk(1060, 1120, 100, "Muse", "a2"), + mk(1500, 1560, 100, "Muse", "b1"), // 380s gap → separate + mk(1560, 1620, 100, "Muse", "b2"), + ]; + entries.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let merged = super::collapse_adjacent_chunks(entries); + assert_eq!(merged.len(), 2, "two distinct sessions must remain"); + assert_eq!(merged[0].chunk_count, 2); + assert_eq!(merged[1].chunk_count, 2); + } + + #[test] + fn collapse_keeps_different_devices_separate() { + let mut entries = vec![ + mk(1000, 1060, 100, "Muse", "muse1"), + mk(1060, 1120, 100, "Muse", "muse2"), + mk(1120, 1180, 100, "OpenBCI", "obci1"), // device swap + mk(1180, 1240, 100, "OpenBCI", "obci2"), + ]; + entries.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let merged = super::collapse_adjacent_chunks(entries); + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].device_name.as_deref(), Some("OpenBCI")); + assert_eq!(merged[0].chunk_count, 2); + assert_eq!(merged[1].device_name.as_deref(), Some("Muse")); + assert_eq!(merged[1].chunk_count, 2); + } + + #[test] + fn collapse_tolerates_gaps_within_5s() { + // 3-second BLE jitter between chunks — must still merge. + let mut entries = vec![ + mk(1000, 1060, 100, "Muse", "a"), + mk(1063, 1123, 100, "Muse", "b"), // 3s gap + mk(1128, 1188, 100, "Muse", "c"), // 5s gap (boundary) + ]; + entries.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let merged = super::collapse_adjacent_chunks(entries); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].chunk_count, 3); + } + + #[test] + fn collapse_breaks_on_6s_gap() { + let mut entries = vec![ + mk(1000, 1060, 100, "Muse", "a"), + mk(1066, 1126, 100, "Muse", "b"), // 6s gap → split + ]; + entries.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let merged = super::collapse_adjacent_chunks(entries); + assert_eq!(merged.len(), 2); + } + + #[test] + fn collapse_singleton_unchanged() { + let entries = vec![mk(1000, 1060, 100, "Muse", "solo")]; + let merged = super::collapse_adjacent_chunks(entries); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].chunk_count, 1); + assert!(merged[0].chunks.is_none(), "singleton has no chunks list"); + } + + #[test] + fn collapse_empty_input() { + let merged = super::collapse_adjacent_chunks(vec![]); + assert!(merged.is_empty()); + } } diff --git a/crates/skill-history/src/local_days.rs b/crates/skill-history/src/local_days.rs index bc58cb84..f0aa2d57 100644 --- a/crates/skill-history/src/local_days.rs +++ b/crates/skill-history/src/local_days.rs @@ -192,7 +192,11 @@ pub fn list_sessions_for_local_day( tb.cmp(&ta) }); - merged + // Second pass: a session that crosses UTC midnight has its chunks + // split across two day dirs. Each per-dir collapse only sees its own + // half, leaving two adjacent entries here. Re-collapse so the local- + // day listing shows one logical session. + crate::collapse_adjacent_chunks_pub(merged) } /// List ALL sessions across ALL days, newest first. From f42792cf7ec323cf877fc2efdedcd368c514fec8 Mon Sep 17 00:00:00 2001 From: Eugene Hauptmann Date: Tue, 5 May 2026 02:42:57 -0400 Subject: [PATCH 38/98] 24 hours test --- crates/skill-daemon/src/session/runner.rs | 197 +++++++++++++++++++--- crates/skill-history/src/lib.rs | 43 +++-- src/lib/history/history-helpers.ts | 8 + src/routes/history/+page.svelte | 9 + 4 files changed, 228 insertions(+), 29 deletions(-) diff --git a/crates/skill-daemon/src/session/runner.rs b/crates/skill-daemon/src/session/runner.rs index 9e70a0d1..d2238345 100644 --- a/crates/skill-daemon/src/session/runner.rs +++ b/crates/skill-daemon/src/session/runner.rs @@ -39,20 +39,24 @@ pub(crate) async fn run_adapter_session( // Session rollover: bound the blast radius of a daemon crash to ≤ N // minutes of data, and keep individual files small enough for readers. - // Configurable via `session_rollover_minutes` (0 = disabled). - let rollover_secs: u64 = { - let settings = skill_settings::load_settings(&skill_dir); - u64::from(settings.session_rollover_minutes).saturating_mul(60) - }; - // When disabled, use a finite-but-effectively-infinite duration. The - // select arm gates on `rollover_secs > 0` so the sleep is never read, - // but the Sleep future still exists and its deadline must not overflow - // tokio's internal Instant arithmetic — so cap at ~10 years. - let rollover_duration = if rollover_secs == 0 { - std::time::Duration::from_secs(60 * 60 * 24 * 365 * 10) - } else { - std::time::Duration::from_secs(rollover_secs) - }; + // Configurable via `session_rollover_minutes` (0 = disabled). The + // value is re-read from settings every time the timer fires so the + // user can change the interval (or disable rollover entirely) mid- + // session without restarting the recording. + fn read_rollover_duration(skill_dir: &std::path::Path) -> (u64, std::time::Duration) { + let secs = u64::from(skill_settings::load_settings(skill_dir).session_rollover_minutes).saturating_mul(60); + // When disabled, use a finite-but-effectively-infinite duration. + // The select arm gates on `secs > 0` so the sleep is never read, + // but the Sleep future still exists and its deadline must not + // overflow tokio's internal Instant arithmetic — so cap at ~10y. + let dur = if secs == 0 { + std::time::Duration::from_secs(60 * 60 * 24 * 365 * 10) + } else { + std::time::Duration::from_secs(secs) + }; + (secs, dur) + } + let (mut rollover_secs, mut rollover_duration) = read_rollover_duration(&skill_dir); let rollover_sleep = tokio::time::sleep(rollover_duration); tokio::pin!(rollover_sleep); @@ -75,11 +79,21 @@ pub(crate) async fn run_adapter_session( break; } () = &mut rollover_sleep, if rollover_secs > 0 && pipeline.is_some() => { - if let Some(ref mut pipe) = pipeline { - if let Err(e) = pipe.roll(&skill_dir) { - error!(%e, "session rollover failed; continuing on existing writer"); - } else if let Ok(mut s) = state.status.lock() { - s.csv_path = Some(pipe.csv_path.display().to_string()); + // Hot-reload settings BEFORE firing so a user disabling + // rollover (or extending the interval) gets honoured + // immediately — without this re-read, the already-armed + // sleep would still fire one more time after the change. + let (new_secs, new_dur) = read_rollover_duration(&skill_dir); + rollover_secs = new_secs; + rollover_duration = new_dur; + + if rollover_secs > 0 { + if let Some(ref mut pipe) = pipeline { + if let Err(e) = pipe.roll(&skill_dir) { + error!(%e, "session rollover failed; continuing on existing writer"); + } else if let Ok(mut s) = state.status.lock() { + s.csv_path = Some(pipe.csv_path.display().to_string()); + } } } rollover_sleep.as_mut().reset(tokio::time::Instant::now() + rollover_duration); @@ -2125,4 +2139,149 @@ mod tests { "expected exactly 1 chunk with rollover disabled, got {chunks:?}" ); } + + /// A `Disconnected` event arriving exactly at the rollover boundary + /// must not panic, deadlock, or leave the daemon in a stuck state. + /// The biased `select!` order is `cancel > idle > rollover > event`, + /// so when the rollover_sleep and the next adapter event are both + /// ready in the same poll, rollover fires first and the disconnect + /// is processed on the next iteration. Either ordering must produce + /// a clean shutdown with the existing chunk(s) finalised. + #[tokio::test(start_paused = true)] + async fn rollover_and_disconnect_race_clean_shutdown() { + let dir = TempDir::new().unwrap(); + write_settings_with_rollover(dir.path(), 1); + let state = test_state(dir.path()); + + // Adapter pushes events at exactly 1s/event so by event #60 we + // are at fake-time 60s, the rollover boundary. The 60th event + // is `Disconnected` — racing with rollover_sleep's fire. + let mut adapter = MockAdapter::new(eeg_desc("muse", 4, 256.0)).with_delay(Duration::from_secs(1)); + adapter.push(DeviceEvent::Connected(DeviceInfo { + name: "Muse-Race".to_string(), + id: "mock:race".to_string(), + ..Default::default() + })); + for i in 0..58 { + adapter.push(DeviceEvent::Eeg(EegFrame { + channels: vec![1.0, 2.0, 3.0, 4.0], + timestamp_s: i as f64 / 256.0, + })); + } + // Event 59 brings us to fake-time ≈ 60s, the rollover instant. + adapter.push(DeviceEvent::Disconnected); + + let state_check = state.clone(); + run(state, adapter).await; + + // Daemon must end up disconnected, not stuck. + assert_eq!(state_check.status.lock().unwrap().state, "disconnected"); + + // At least one chunk must exist and have a sidecar — proves the + // pipeline finalised cleanly even under the race. + let chunks: Vec<_> = files_recursive(dir.path()) + .into_iter() + .filter(|p| { + let s = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + s.starts_with("exg_") + && s.ends_with(".csv") + && !s.contains("_imu") + && !s.contains("_ppg") + && !s.contains("_metrics") + && !s.contains("_fnirs") + }) + .collect(); + assert!(!chunks.is_empty(), "at least one chunk written before disconnect"); + for c in &chunks { + let sidecar = c.with_extension("json"); + assert!( + sidecar.exists(), + "every chunk must have a sidecar (partial or full): {c:?}" + ); + } + } + + /// Mid-session, the user disables rollover by writing + /// `session_rollover_minutes = 0` to settings.json. The first roll + /// (already armed) fires on schedule, then re-reads settings and + /// stops scheduling further rolls. So a long run produces exactly + /// 2 chunks: the initial one + the one from the first roll, with + /// no further rolls after the setting flips to 0. + #[tokio::test(start_paused = true)] + async fn rollover_setting_hot_reloads_to_disabled() { + let dir = TempDir::new().unwrap(); + write_settings_with_rollover(dir.path(), 1); + let state = test_state(dir.path()); + + // Adapter pushes an event every 2 fake-seconds for ~5 minutes + // of fake time (well past 2 rollover boundaries at minutes=1). + let mut adapter = MockAdapter::new(eeg_desc("muse", 4, 256.0)).with_delay(Duration::from_secs(2)); + adapter.push(DeviceEvent::Connected(DeviceInfo { + name: "Muse-HotReload".to_string(), + id: "mock:hotreload".to_string(), + firmware_version: Some("hr-1".to_string()), + ..Default::default() + })); + // Half of the events use rollover=1min; we'll switch to 0 + // (disabled) once the runner has had a chance to do its first + // roll. At 2s/event, ~30 events ≈ 60s = first roll boundary. + for i in 0..150 { + // After ~70s of fake time (35 events), flip the setting. + if i == 35 { + adapter.push(DeviceEvent::Eeg(EegFrame { + channels: vec![1.0, 2.0, 3.0, 4.0], + timestamp_s: i as f64 / 256.0, + })); + // Stash a marker event — we'll inject the settings + // flip in a parallel task because the adapter queue + // is drained inside the daemon. + continue; + } + adapter.push(DeviceEvent::Eeg(EegFrame { + channels: vec![1.0, 2.0, 3.0, 4.0], + timestamp_s: i as f64 / 256.0, + })); + } + adapter.push(DeviceEvent::Disconnected); + + // Spawn a parallel task that flips the rollover setting to 0 + // after enough fake time has elapsed for one roll to have + // happened. Real-time sleep here is a small grace period; the + // tokio runtime auto-advances fake time across the adapter's + // 2s sleeps so this fires after the first roll. + let dir_path = dir.path().to_path_buf(); + let flipper = tokio::spawn(async move { + // Wait for ~70s of fake time. The settings flip is on the + // real filesystem so it doesn't depend on tokio's clock. + tokio::time::sleep(Duration::from_secs(70)).await; + write_settings_with_rollover(&dir_path, 0); + }); + + run(state, adapter).await; + let _ = flipper.await; + + let chunks: Vec<_> = files_recursive(dir.path()) + .into_iter() + .filter(|p| { + let s = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + s.starts_with("exg_") + && s.ends_with(".csv") + && !s.contains("_imu") + && !s.contains("_ppg") + && !s.contains("_metrics") + && !s.contains("_fnirs") + }) + .collect(); + + // With hot-reload disabled, we'd see 4–5 chunks (one per minute + // of fake time). With hot-reload working, we see exactly 2: + // the initial chunk + the one created at t=60s (first roll). + // After t=70s the setting flips to 0; no further rolls fire. + assert_eq!( + chunks.len(), + 2, + "hot-reload to disabled must stop rollover after the next fire, got {} chunks", + chunks.len() + ); + } } diff --git a/crates/skill-history/src/lib.rs b/crates/skill-history/src/lib.rs index 3f603bc3..33764f49 100644 --- a/crates/skill-history/src/lib.rs +++ b/crates/skill-history/src/lib.rs @@ -732,10 +732,14 @@ fn collapse_adjacent_chunks(sorted_desc: Vec) -> Vec head.mac_address = head.mac_address.clone().or(s.mac_address); head.hardware_version = head.hardware_version.clone().or(s.hardware_version); head.sample_rate_hz = head.sample_rate_hz.or(s.sample_rate_hz); - // Keep all chunk paths; the canonical csv_path stays as head's. + // Keep all chunk paths in oldest → newest order. We walk + // newest → older (input sorted DESC), so prepend each absorbed + // chunk to the front. This stays correct under repeated calls + // (e.g. the second collapse in `list_sessions_for_local_day`) + // — no post-pass reverse, which would flip an already-correct + // list. let chunks = head.chunks.get_or_insert_with(|| vec![head.csv_path.clone()]); - chunks.push(s.csv_path); - // Concatenate labels (oldest first → newest last after the rev). + chunks.insert(0, s.csv_path); head.labels.extend(s.labels); // avg_snr_db: take the simple average of available values for now // (a sample-weighted average would require keeping per-chunk @@ -746,13 +750,6 @@ fn collapse_adjacent_chunks(sorted_desc: Vec) -> Vec (None, None) => None, }; } - // Each merged entry's `chunks` was built head-then-older; reverse so - // it reads oldest → newest, matching frontend expectations. - for entry in &mut out { - if let Some(c) = entry.chunks.as_mut() { - c.reverse(); - } - } out } @@ -1675,4 +1672,30 @@ mod session_listing_tests { let merged = super::collapse_adjacent_chunks(vec![]); assert!(merged.is_empty()); } + + /// Calling collapse twice must not flip the `chunks` ordering. + /// Important because `list_sessions_for_local_day` re-collapses the + /// merged result of `list_sessions_for_day` to handle UTC-midnight + /// crossings. + #[test] + fn collapse_is_idempotent_for_chunks_order() { + let mut entries: Vec = (0..10) + .map(|i| { + let start = 1_700_000_000 + i as u64 * 60; + mk(start, start + 60, 1000, "Muse", &format!("p{i}")) + }) + .collect(); + entries.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let pass1 = super::collapse_adjacent_chunks(entries); + let pass1_chunks = pass1[0].chunks.clone().unwrap(); + + let pass2 = super::collapse_adjacent_chunks(pass1); + let pass2_chunks = pass2[0].chunks.clone().unwrap(); + + assert_eq!(pass1_chunks, pass2_chunks, "second collapse must not reorder"); + assert_eq!(pass1_chunks.first().unwrap(), "p0", "oldest first"); + assert_eq!(pass1_chunks.last().unwrap(), "p9", "newest last"); + assert_eq!(pass2[0].chunk_count, 10, "chunk_count preserved through re-collapse"); + } } diff --git a/src/lib/history/history-helpers.ts b/src/lib/history/history-helpers.ts index 0ebe5588..22cf735f 100644 --- a/src/lib/history/history-helpers.ts +++ b/src/lib/history/history-helpers.ts @@ -26,6 +26,14 @@ export interface SessionEntry { file_size_bytes: number; /** Average signal-to-noise ratio (dB) for the session. `null` for very old sessions. */ avg_snr_db: number | null; + /** + * Number of underlying rollover chunks merged into this entry. `1` for + * ordinary single-chunk sessions; `>1` when adjacent same-device chunks + * were collapsed into one logical session by the backend. + */ + chunk_count?: number; + /** CSV paths of every chunk in this logical session, oldest first. */ + chunks?: string[]; } export interface HistoryStatsData { diff --git a/src/routes/history/+page.svelte b/src/routes/history/+page.svelte index ee3fd7d9..ecad1f9a 100644 --- a/src/routes/history/+page.svelte +++ b/src/routes/history/+page.svelte @@ -1947,6 +1947,15 @@ useWindowTitle("window.title.history"); {/if} + {#if (session.chunk_count ?? 1) > 1} + + {session.chunk_count} chunks + + {/if} + {#if session.labels.length > 0} @@ -409,11 +409,11 @@ function qualityBg(val: number): string { {name} - + {labelToText(label)} - {musePositionLabels[idx]} + {musePositionLabels[idx]} {/each} @@ -487,7 +487,7 @@ function qualityBg(val: number): string {
{#each Object.entries(regionColors) as [region, color]} {#if region !== "reference"} @@ -500,7 +500,7 @@ function qualityBg(val: number): string {
-
Drag to rotate · Click electrode
@@ -515,9 +515,9 @@ function qualityBg(val: number): string { {el.name} {#if el.muse} - Muse + Muse {/if} - {regionLabels[el.region]} + {regionLabels[el.region]} {#if el.muse && effectiveQuality} {@const chIdx = museChannels.indexOf(el.name)} {#if chIdx >= 0} diff --git a/src/lib/charts/InteractiveGraph3D.svelte b/src/lib/charts/InteractiveGraph3D.svelte index ebc839b3..613c3a2a 100644 --- a/src/lib/charts/InteractiveGraph3D.svelte +++ b/src/lib/charts/InteractiveGraph3D.svelte @@ -1097,7 +1097,7 @@ function fmtTs(unix: number) {
-
+
{#each LEGEND_BASE as l}
@@ -1127,7 +1127,7 @@ function fmtTs(unix: number) { Screenshot link
{/if} - + hover · click to highlight · click again to clear
@@ -1135,7 +1135,7 @@ function fmtTs(unix: number) { {#if eegDots.length > 0}
- + EEG node time scale {/each} - + {eegDots.length} EEG point{eegDots.length !== 1 ? "s" : ""} · {fmtTs(eegTimeMin)} → {fmtTs(eegTimeMax)}
{:else} -
+
EEG nodes colored by session time (turbo gradient)
{/if} diff --git a/src/lib/chat/ChatMessageList.svelte b/src/lib/chat/ChatMessageList.svelte index 12b5b280..790610ec 100644 --- a/src/lib/chat/ChatMessageList.svelte +++ b/src/lib/chat/ChatMessageList.svelte @@ -119,7 +119,7 @@ function copyMessage(msg: Message) {

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

-
+
{#if expanded} diff --git a/src/lib/dashboard/CompositeScores.svelte b/src/lib/dashboard/CompositeScores.svelte index 7c268f0a..b618785c 100644 --- a/src/lib/dashboard/CompositeScores.svelte +++ b/src/lib/dashboard/CompositeScores.svelte @@ -28,7 +28,7 @@ let { meditation, cognitiveLoad, drowsiness }: Props = $props();
- {t(`dashboard.${item.k}`)} + {t(`dashboard.${item.k}`)} {item.v.toFixed(0)}
diff --git a/src/lib/dashboard/ConsciousnessMetrics.svelte b/src/lib/dashboard/ConsciousnessMetrics.svelte index 3ab346c2..820558cd 100644 --- a/src/lib/dashboard/ConsciousnessMetrics.svelte +++ b/src/lib/dashboard/ConsciousnessMetrics.svelte @@ -45,7 +45,7 @@ const items = $derived([
- + {t(`dashboard.consciousness.${item.k}`)}
- /100 + /100
{/each} diff --git a/src/lib/dashboard/EegIndices.svelte b/src/lib/dashboard/EegIndices.svelte index 166c4a2c..3db79268 100644 --- a/src/lib/dashboard/EegIndices.svelte +++ b/src/lib/dashboard/EegIndices.svelte @@ -105,7 +105,7 @@ let {
- {t(`dashboard.${item.k}`)} + {t(`dashboard.${item.k}`)} {item.v}
{#if item.bar !== undefined} diff --git a/src/lib/dashboard/FaaGauge.svelte b/src/lib/dashboard/FaaGauge.svelte index 559e84aa..9b477b3a 100644 --- a/src/lib/dashboard/FaaGauge.svelte +++ b/src/lib/dashboard/FaaGauge.svelte @@ -38,7 +38,7 @@ let { faa }: Props = $props(); background: linear-gradient(270deg, var(--color-violet-400), var(--color-violet-500))">
{/if}
-
+
{t("dashboard.faaWithdrawal")} {t("dashboard.faaFormula")} {t("dashboard.faaApproach")} diff --git a/src/lib/dashboard/HeadPoseCard.svelte b/src/lib/dashboard/HeadPoseCard.svelte index 704c6e5a..8a8a27aa 100644 --- a/src/lib/dashboard/HeadPoseCard.svelte +++ b/src/lib/dashboard/HeadPoseCard.svelte @@ -24,7 +24,7 @@ let { pitch, roll, stillness, nodCount, shakeCount }: Props = $props();
- {t("dashboard.pitch")} + {t("dashboard.pitch")} {pitch >= 0 ? "+" : ""}{pitch.toFixed(1)}°
@@ -36,7 +36,7 @@ let { pitch, roll, stillness, nodCount, shakeCount }: Props = $props();
- {t("dashboard.roll")} + {t("dashboard.roll")} {roll >= 0 ? "+" : ""}{roll.toFixed(1)}°
@@ -48,7 +48,7 @@ let { pitch, roll, stillness, nodCount, shakeCount }: Props = $props();
- {t("dashboard.stillness")} + {t("dashboard.stillness")} {stillness.toFixed(0)}
@@ -59,7 +59,7 @@ let { pitch, roll, stillness, nodCount, shakeCount }: Props = $props();
- {t("dashboard.nods")} + {t("dashboard.nods")} {nodCount}
@@ -67,7 +67,7 @@ let { pitch, roll, stillness, nodCount, shakeCount }: Props = $props();
- {t("dashboard.shakes")} + {t("dashboard.shakes")} {shakeCount}
diff --git a/src/lib/dashboard/PpgMetrics.svelte b/src/lib/dashboard/PpgMetrics.svelte index 101082a7..648e3078 100644 --- a/src/lib/dashboard/PpgMetrics.svelte +++ b/src/lib/dashboard/PpgMetrics.svelte @@ -40,7 +40,7 @@ let { hr, rmssd, sdnn, pnn50, lfHf, respRate, spo2, perfIdx, stressIdx }: Props
- {t(`dashboard.${item.k}`)} + {t(`dashboard.${item.k}`)} {item.v}
diff --git a/src/lib/dashboard/SessionDetail.svelte b/src/lib/dashboard/SessionDetail.svelte index 0a6e1fec..598372f9 100644 --- a/src/lib/dashboard/SessionDetail.svelte +++ b/src/lib/dashboard/SessionDetail.svelte @@ -219,10 +219,10 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l}
{item.v} - {t("sd.outOf100")} + {t("sd.outOf100")}
@@ -241,7 +241,7 @@ export interface CsvMetricsResult { {isOpen(id) ? 'rotate-90' : ''}"> - {label} {/snippet} @@ -259,7 +259,7 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l} {item.v}
@@ -291,7 +291,7 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l} {item.v}
@@ -314,7 +314,7 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l} {item.v}
@@ -340,7 +340,7 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l} {item.v}
@@ -365,7 +365,7 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l} {item.v}
diff --git a/src/lib/dashboard/TimeSeriesChart.svelte b/src/lib/dashboard/TimeSeriesChart.svelte index 5a3d3714..702f77a3 100644 --- a/src/lib/dashboard/TimeSeriesChart.svelte +++ b/src/lib/dashboard/TimeSeriesChart.svelte @@ -744,7 +744,7 @@ onDestroy(() => {
{#if yLabel} - {yLabel} + {yLabel} {/if}
@@ -758,7 +758,7 @@ onDestroy(() => { ondblclick={onDblClick}> {#if zoomXMin !== undefined} diff --git a/src/lib/history/HistoryCalendar.svelte b/src/lib/history/HistoryCalendar.svelte index c7577752..0b2e29e7 100644 --- a/src/lib/history/HistoryCalendar.svelte +++ b/src/lib/history/HistoryCalendar.svelte @@ -100,14 +100,14 @@ let {
-
+
{#each ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as month, i} {month} {/each}
-
+
{t("history.heatmap.less")} {#each [0,1,2,3,4] as level}
{#each ["S","M","T","W","T","F","S"] as wd} -
{wd}
+
{wd}
{/each}
@@ -171,7 +171,7 @@ let {
{#each [0,3,6,9,12,15,18,21] as hr} -
+
{hr.toString().padStart(2,"0")}:00
{/each} diff --git a/src/lib/history/HistoryStatsBar.svelte b/src/lib/history/HistoryStatsBar.svelte index a53221e9..974ad3e3 100644 --- a/src/lib/history/HistoryStatsBar.svelte +++ b/src/lib/history/HistoryStatsBar.svelte @@ -43,16 +43,16 @@ let { daysCount, totalHours, recordingStreak, historyStats, weekTrend }: Props =
{daysCount} - {t("history.days")} + {t("history.days")}
{#if historyStats}
{totalHours.toFixed(1)} - {t("history.hours")} + {t("history.hours")}
{historyStats.total_sessions} - {t("history.sessions")} + {t("history.sessions")}
{#if weekTrend && (weekTrend.thisWeek > 0 || weekTrend.lastWeek > 0)}
@@ -65,7 +65,7 @@ let { daysCount, totalHours, recordingStreak, historyStats, weekTrend }: Props = {/if}
- {t("history.thisWeek")} + {t("history.thisWeek")}
{/if} {/if} diff --git a/src/lib/history/SessionMap.svelte b/src/lib/history/SessionMap.svelte index fb499b95..fc0f43ea 100644 --- a/src/lib/history/SessionMap.svelte +++ b/src/lib/history/SessionMap.svelte @@ -296,7 +296,7 @@ $effect(() => { aria-label="Session location map" >
{#if usingIpFallback} -

+

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

{/if} diff --git a/src/lib/settings/ActivityTab.svelte b/src/lib/settings/ActivityTab.svelte index 6e7db1bd..bff923ec 100644 --- a/src/lib/settings/ActivityTab.svelte +++ b/src/lib/settings/ActivityTab.svelte @@ -504,9 +504,9 @@ let heatmapMax = $state(1);
{#each tfb as row}
-
{row.focus_level} focus
+
{row.focus_level} focus
{Math.round(row.fail_rate * 100)}%
-
fail rate ({row.passes + row.fails} runs)
+
fail rate ({row.passes + row.fails} runs)
{/each}
@@ -526,7 +526,7 @@ let heatmapMax = $state(1);
{Math.round(row.avg_focus)} - undo {row.undo_rate.toFixed(1)} + undo {row.undo_rate.toFixed(1)}
{/each}
@@ -542,7 +542,7 @@ let heatmapMax = $state(1);
{row.app} {row.delta > 0 ? '+' : ''}{row.delta.toFixed(1)} - vs {Math.round(ai.baseline_focus)} baseline ({row.message_count} msgs) + vs {Math.round(ai.baseline_focus)} baseline ({row.message_count} msgs)
{/each}
@@ -574,7 +574,7 @@ let heatmapMax = $state(1); {@const maxChurn = Math.max(1, ...hp.map((r: any) => r.churn))}
- {row.hour} + {row.hour}
{/each}
@@ -624,7 +624,7 @@ let heatmapMax = $state(1);
-

+

Today

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

+

Code

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

+

Terminal

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

+

AI & Web

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

{browserDistraction.suggestion}

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

{browserProcrast.suggestion}

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

+

Trends

diff --git a/src/lib/settings/AppearanceTab.svelte b/src/lib/settings/AppearanceTab.svelte index 38d62307..4ccf3330 100644 --- a/src/lib/settings/AppearanceTab.svelte +++ b/src/lib/settings/AppearanceTab.svelte @@ -174,7 +174,7 @@ const THEME_OPTIONS: { value: ThemeMode; icon: string; labelKey: string }[] = [
- {EEG_CH[i]} + {EEG_CH[i]}
{/each} @@ -184,7 +184,7 @@ const THEME_OPTIONS: { value: ThemeMode; icon: string; labelKey: string }[] = [
- + {["δ","θ","α","β","γ"][i]}
diff --git a/src/lib/settings/ClientsTab.svelte b/src/lib/settings/ClientsTab.svelte index b9715aba..e9710177 100644 --- a/src/lib/settings/ClientsTab.svelte +++ b/src/lib/settings/ClientsTab.svelte @@ -470,7 +470,7 @@ onDestroy(() => stopPolling());
{g.label} - dangerous + dangerous
{g.description}
diff --git a/src/lib/settings/DevicesTab.svelte b/src/lib/settings/DevicesTab.svelte index 0f4ef303..73cb04ec 100644 --- a/src/lib/settings/DevicesTab.svelte +++ b/src/lib/settings/DevicesTab.svelte @@ -779,7 +779,7 @@ onDestroy(() => { bg-clip-text text-transparent"> {pairedDevices.length} - + {t("devices.pairedCount", { n: String(pairedDevices.length) })}
@@ -804,7 +804,7 @@ onDestroy(() => {
- 🔬 {t("devices.virtualDevices")}
{:else if i > 0} @@ -860,10 +860,10 @@ onDestroy(() => {
- + 🔬 {t("devices.virtualDevices")} - — {t("devices.virtualDevicesHint")} + — {t("devices.virtualDevicesHint")}
{#each discoveredVirtual as dev, i (dev.id)} {#if i > 0}{/if} @@ -880,10 +880,10 @@ onDestroy(() => { {(discoveredReal.length > 0 || discoveredVirtual.length > 0) ? 'border-t border-border dark:border-white/[0.06]' : ''}"> - + 🧭 {t("devices.manualHints")} - — {t("devices.manualHintsHint")} + — {t("devices.manualHintsHint")}
{#each manualHintDevices as dev, i (dev.id)} {#if i > 0}{/if} @@ -947,7 +947,7 @@ onDestroy(() => {
{t(item.name_key)} {#if item.ios_only} - 📱 {t("devices.iosOnly")} + 📱 {t("devices.iosOnly")} {/if}
{/each} @@ -1766,13 +1766,13 @@ onDestroy(() => { {/if} {#if isVirtualDevice(dev)} 🔬 {t("devices.virtualBadge")} {:else if dev.transport && dev.transport !== "ble"} { bg-clip-text text-transparent"> {dailyGoalMin}m
- + {goalHours >= 1 ? `${goalHours.toFixed(1)} hours` : `${dailyGoalMin} minutes`} / day {#if streak > 0} @@ -427,7 +427,7 @@ const streak = $derived.by(() => { bind:value={dailyGoalMin} oninput={save} class="w-full accent-violet-500 h-2" /> -
+
5m 1h 2h @@ -469,7 +469,7 @@ const streak = $derived.by(() => {
- + {fmtMins(dailyGoalMin)}
@@ -509,7 +509,7 @@ const streak = $derived.by(() => {
-
+
{chartDays[0]?.label ?? ""} {chartDays[Math.floor(chartDays.length / 2)]?.label ?? ""} {t("goals.today")} @@ -636,7 +636,7 @@ const streak = $derived.by(() => { value={dndConfig.focus_threshold} oninput={(e) => setDndThreshold(Number((e.currentTarget as HTMLInputElement).value))} class="w-full accent-violet-500 h-2" /> -
+
10 40 60 @@ -854,7 +854,7 @@ const streak = $derived.by(() => {
-
{#if scoreAbove} 0s @@ -918,7 +918,7 @@ const streak = $derived.by(() => {
-
{#if isCounting} 0s diff --git a/src/lib/settings/LslTab.svelte b/src/lib/settings/LslTab.svelte index a6731dba..ffaed5d9 100644 --- a/src/lib/settings/LslTab.svelte +++ b/src/lib/settings/LslTab.svelte @@ -626,7 +626,7 @@ onDestroy(() => {
{t("lsl.recording")} @@ -891,7 +891,7 @@ onDestroy(() => { > {#if stream.paired} {t("lsl.paired")} diff --git a/src/lib/settings/SleepTab.svelte b/src/lib/settings/SleepTab.svelte index 53a61296..6d89bef6 100644 --- a/src/lib/settings/SleepTab.svelte +++ b/src/lib/settings/SleepTab.svelte @@ -163,7 +163,7 @@ function arcPath(startAngle: number, endAngle: number, r: number): string { bg-clip-text text-transparent"> {durationLabel(duration.total)} - + {config.bedtime} — {config.wake_time}
diff --git a/src/lib/settings/TerminalSessionsCard.svelte b/src/lib/settings/TerminalSessionsCard.svelte index 32ca6331..709f1ec3 100644 --- a/src/lib/settings/TerminalSessionsCard.svelte +++ b/src/lib/settings/TerminalSessionsCard.svelte @@ -424,11 +424,11 @@ function toggleDay(key: string) { class="flex w-full items-baseline gap-2 rounded px-1 py-0.5 text-left hover:bg-muted/30" onclick={() => toggleDay(bucket.key)} > - {collapsed ? "▸" : "▾"} + {collapsed ? "▸" : "▾"} {bucket.label} - + {bucket.rows.length} session{bucket.rows.length === 1 ? "" : "s"} @@ -479,11 +479,11 @@ function toggleDay(key: string) { {/if} {#if isLive} - + live {:else if isLegacy} - + legacy {/if} diff --git a/src/lib/settings/TerminalTab.svelte b/src/lib/settings/TerminalTab.svelte index 4cf7ff9c..344417e4 100644 --- a/src/lib/settings/TerminalTab.svelte +++ b/src/lib/settings/TerminalTab.svelte @@ -297,7 +297,7 @@ function categoryColor(cat: string): string { ! {/if} - {cmd.category} + {cmd.category} {cmd.command}
{/each} diff --git a/src/lib/settings/TlxForm.svelte b/src/lib/settings/TlxForm.svelte index 11372f8e..5f9349f4 100644 --- a/src/lib/settings/TlxForm.svelte +++ b/src/lib/settings/TlxForm.svelte @@ -153,7 +153,7 @@ async function submit() { oninput={(e) => scale.set(Number((e.target as HTMLInputElement).value))} class="mt-1 w-full" /> -
+
{scale.inverted ? t("validation.tlx.failure") : t("validation.tlx.low")} diff --git a/src/lib/settings/TokensTab.svelte b/src/lib/settings/TokensTab.svelte index 868a256d..0e2d7096 100644 --- a/src/lib/settings/TokensTab.svelte +++ b/src/lib/settings/TokensTab.svelte @@ -159,8 +159,8 @@ onMount(refresh);
{t("tokens.defaultToken")} - admin - {t("tokens.expiryNever")} + admin + {t("tokens.expiryNever")}
{#if imuExpanded} @@ -2402,7 +2402,7 @@ useWindowTitle("window.title.main"); group-hover:text-foreground transition-colors"> {t("dashboard.eegChannels")} - + {#if eegChExpanded}
4 && chLabels.length <= 8} class:grid-cols-4={chLabels.length > 8}> @@ -2447,7 +2447,7 @@ useWindowTitle("window.title.main"); ] as [label, val]}
- {label} + {label} {val}
{/each} @@ -2659,7 +2659,7 @@ useWindowTitle("window.title.main"); {sess.device_name} - {sess.device_kind === "lsl" ? "LSL" : sess.device_kind === "lsl-iroh" ? "iroh" : sess.device_kind.toUpperCase()} diff --git a/src/routes/calibration/+page.svelte b/src/routes/calibration/+page.svelte index 3d8ecc4f..2b85c00b 100644 --- a/src/routes/calibration/+page.svelte +++ b/src/routes/calibration/+page.svelte @@ -481,7 +481,7 @@ useWindowTitle("window.title.calibration"); : 'text-muted-foreground border-border dark:border-white/[0.06] hover:text-foreground hover:border-foreground/30'}" > {etab.label} - {etab.count} + {etab.count} {/each} {#if !museConnected} @@ -506,11 +506,11 @@ useWindowTitle("window.title.calibration");
{name} - {elecQualityText(label)} - {MUSE_POSITIONS[idx]} + {MUSE_POSITIONS[idx]}
{/each}
@@ -530,7 +530,7 @@ useWindowTitle("window.title.calibration"); style="background:{elecQualityColor(label)}"> {name} - + {elecQualityText(label)}
diff --git a/src/routes/compare/+page.svelte b/src/routes/compare/+page.svelte index 8cce04d9..e719cbfd 100644 --- a/src/routes/compare/+page.svelte +++ b/src/routes/compare/+page.svelte @@ -1067,17 +1067,17 @@ useWindowTitle("window.title.compare"); {#if emb} {#if pct >= 90} - + {:else if pct > 0} - {pct}% {:else} - @@ -1150,14 +1150,14 @@ useWindowTitle("window.title.compare");
{#if dayStr} - {fromUnix(anchor + 43200).toLocaleDateString("default",{weekday:"short",month:"short",day:"numeric"})} {/if} {#if day2Str} - {fromUnix(day2Utc + 43200).toLocaleDateString("default",{weekday:"short",month:"short",day:"numeric"})} @@ -1174,7 +1174,7 @@ useWindowTitle("window.title.compare"); background:{isMidnight?'rgba(148,163,184,0.5)':'rgba(148,163,184,0.2)'}"> {#if hOff % 6 === 0} - {String(localH).padStart(2,"0")} @@ -1211,7 +1211,7 @@ useWindowTitle("window.title.compare"); ring-0 hover:ring-2 hover:ring-white/60 hover:ring-offset-0" style="left:{lp}%; width:{wp}%; background:{clr}; opacity:0.72; z-index:2"> {#if wp > 5} - {dur} + {dur} {/if} {/each} @@ -1225,9 +1225,9 @@ useWindowTitle("window.title.compare");
{#if rw > 8} - {utcToTimeStr(rangeStart)} - {utcToTimeStr(rangeEnd)} {/if}
@@ -1510,7 +1510,7 @@ useWindowTitle("window.title.compare"); bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-ui-xs font-medium"> {d.label} - + {d.pctChange > 0 ? "+" : ""}{d.pctChange.toFixed(0)}% @@ -1525,14 +1525,14 @@ useWindowTitle("window.title.compare"); bg-red-500/10 text-red-500 dark:text-red-400 text-ui-xs font-medium"> {d.label} - + {d.pctChange > 0 ? "+" : ""}{d.pctChange.toFixed(0)}% {/each}
{/if} -

+

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

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