diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..6fb97e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,75 @@ +name: Bug report +description: Something broke. Help us fix it. +title: "[Bug] " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for filing a bug. Please attach a diagnostic bundle if possible — it speeds up triage by 10x. Generate one via Settings → Help → Generate diagnostic bundle. + + - type: input + id: version + attributes: + label: Lumotia version + description: Settings → Help. e.g. v0.1.0 + placeholder: v0.1.0 + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + options: + - Linux (Fedora) + - Linux (Ubuntu LTS) + - Linux (other) + - macOS Apple Silicon + - macOS Intel + - Windows 11 + - Windows 10 + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open Lumotia + 2. Click record + 3. ... + validations: + required: true + + - type: textarea + id: diagnostic-bundle + attributes: + label: Diagnostic bundle + description: Drag-and-drop the .zip from Settings → Help → Generate diagnostic bundle. Never includes audio or transcripts. + validations: + required: false + + - type: textarea + id: extras + attributes: + label: Anything else? + description: Screenshots, logs, related captures. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2e00a55 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: General questions + url: https://github.com/jakeadriansames/lumotia/discussions + about: For general questions or design discussions, use Discussions instead of Issues. diff --git a/.github/ISSUE_TEMPLATE/v0.1-tester-feedback.yml b/.github/ISSUE_TEMPLATE/v0.1-tester-feedback.yml new file mode 100644 index 0000000..cefff12 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/v0.1-tester-feedback.yml @@ -0,0 +1,104 @@ +name: v0.1 Tester feedback +description: You tried Lumotia v0.1. Tell us how it went. +title: "[Tester feedback] " +labels: ["v0.1-tester", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for trying Lumotia v0.1. This template captures the structured feedback the tester-onboarding-kit asks for. + + - type: dropdown + id: platform + attributes: + label: Platform + options: + - Linux (Fedora) + - Linux (Ubuntu LTS) + - Linux (other) + - macOS Apple Silicon + - macOS Intel + - Windows 11 + - Windows 10 + validations: + required: true + + - type: dropdown + id: cold-setup + attributes: + label: Cold-setup pass (steps 1–4) + description: Did you reach "Ready to record" without coaching? + options: + - "Yes — clean" + - "Yes — but I needed help at one step" + - "No — I would have given up" + validations: + required: true + + - type: dropdown + id: warm-activation + attributes: + label: Warm-activation pass (steps 5–10) + description: Did you complete your first real recording within 3 minutes of opening the app? + options: + - "Yes — under 3 min" + - "Yes — but it took longer than 3 min" + - "No — I got stuck" + validations: + required: true + + - type: textarea + id: confusing + attributes: + label: What confused you? + placeholder: One step you had to re-read, one button you couldn't find... + validations: + required: false + + - type: textarea + id: broken + attributes: + label: What broke? + placeholder: Errors you saw, things that didn't work as expected... + validations: + required: false + + - type: textarea + id: cleanup-tasks + attributes: + label: Did the cleanup + task extraction make sense? + placeholder: Was the cleaned transcript better than the raw one? Were the extracted tasks useful? + validations: + required: false + + - type: dropdown + id: would-use-again + attributes: + label: Would you use Lumotia again next week? + options: + - "Definitely" + - "Probably" + - "Maybe" + - "Probably not" + - "Definitely not" + validations: + required: true + + - type: dropdown + id: would-pay + attributes: + label: Would you pay £39 for a Founding Licence? + options: + - "Yes" + - "No" + - "Maybe with one or two changes" + validations: + required: false + + - type: textarea + id: activation-log + attributes: + label: Activation log (optional) + description: Settings → Privacy → Activation log → click rows to copy. Local-only by default — only paste if you're comfortable. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a1666e0 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## What this PR changes + + +## Why + + +## How tested +- [ ] `cargo test --workspace` green +- [ ] `cargo fmt --check` clean +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean +- [ ] `npm run check` 0/0 +- [ ] `npm run test` green +- [ ] `scripts/dogfood-rebrand-drill.sh` 8/8 (if migration / data-dir touched) +- [ ] Manual UI walk-through (if frontend touched) + +## v0.1 scope check +- [ ] Confirms or extends an item in `docs/release/v0.1-checklist.md` +- [ ] Does NOT touch any item in the v0.2 / v0.2-garden-roadmap scope +- [ ] Does NOT remove any privacy invariant (no telemetry, no exfiltration, no AI-driven feature additions outside the locked scope) + +## Notes for the reviewer + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b024eaa..0e18f4c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,13 +14,12 @@ # Promote the draft to a release when ready. # # Signing: -# - macOS code-signing not configured. The .dmg will trigger Gatekeeper -# warnings on the first run; users will need to right-click → Open. -# To wire signing later, set APPLE_SIGNING_IDENTITY + -# APPLE_CERTIFICATE secrets and uncomment the env block. -# - Windows code-signing not configured. The .exe/.msi will trigger -# SmartScreen warnings on first run. To wire signing later, set -# WINDOWS_CERTIFICATE + WINDOWS_CERTIFICATE_PASSWORD secrets. +# - macOS: signing + notarisation activate automatically when the +# APPLE_* secrets are set in the repo. Builds unsigned if absent. +# See docs/release/code-signing-setup.md for the cert walkthrough. +# - Windows: signing activates automatically when WINDOWS_CERTIFICATE +# + WINDOWS_CERTIFICATE_PASSWORD are set. Builds unsigned if absent. +# See docs/release/code-signing-setup.md for the cert walkthrough. name: build on: @@ -48,6 +47,7 @@ jobs: - os: ubuntu-22.04 artifact_glob: | src-tauri/target/release/bundle/appimage/*.AppImage + src-tauri/target/release/bundle/appimage/*.AppImage.sha256 src-tauri/target/release/bundle/deb/*.deb - os: windows-latest artifact_glob: | @@ -143,12 +143,20 @@ jobs: uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Uncomment when signing certs are configured in repo secrets: - # APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - # WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - # WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + # macOS code-signing + notarisation. Builds unsigned if these secrets aren't set. + # Set via: gh secret set APPLE_SIGNING_IDENTITY --body "..." (etc.) + # See docs/release/code-signing-setup.md for the cert procurement walkthrough. + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Windows code-signing. Builds unsigned if these secrets aren't set. + # Set via: gh secret set WINDOWS_CERTIFICATE --body "..." (etc.) + # See docs/release/code-signing-setup.md for the cert procurement walkthrough. + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} with: # If pushed as a tag, use the tag name; otherwise leave empty # so tauri-action builds artifacts but does not touch releases. @@ -159,6 +167,18 @@ jobs: # Build all bundle types the OS supports. args: '' + - name: Compute AppImage SHA-256 + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + cd src-tauri/target/release/bundle/appimage + for img in *.AppImage; do + sha256sum "$img" > "$img.sha256" + echo "Generated: $img.sha256" + cat "$img.sha256" + done + # Always upload as an Actions artifact too — accessible from the # workflow run page even if the release-creation step was skipped. - name: Upload artifacts diff --git a/.gitignore b/.gitignore index 40d9f1e..d956d1e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,18 @@ dist/ .firecrawl/ .worktrees/ .cargo/ +.lumotia-last-audit + +# v0.2 frontend tooling artefacts +reports/ +.playwright/ +test-results/ +playwright-report/ + +# Vite-loaded env overrides — local-only by design. +.env.local +.env.*.local + +# Python bytecode (from release scripts under scripts/) +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0a8255d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable user-facing changes to Lumotia are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/). + +## [Unreleased] + +## [0.1.0] - 2026-MM-DD + +### Added + +- **Dictation with on-device speech recognition.** Speak naturally; Lumotia transcribes locally using Whisper or Parakeet. Six Whisper variants (Tiny through Distil-Large v3) and Parakeet for lower-latency English transcription. The first-run hardware probe selects the fastest-accurate pair for your machine. Models download once and run entirely offline. +- **Live streaming transcription.** Audio is processed as you speak rather than in one batch at the end, so you see results as they arrive. Speech-gated chunking and a duplicate-boundary filter keep the output clean. +- **Transcript cleanup without touching your original.** A rule-based pass removes filler words, collapses repetition, and applies consistent spelling. If you have a local LLM model downloaded, it runs a second pass for natural-language polish. Your raw Whisper transcript is always preserved — cleanup is additive, never destructive. +- **Automatic task extraction with a safe fallback.** Lumotia identifies action items from your transcript using either the local LLM or a rule-based verb-list extractor. If the LLM fails, the fallback runs silently and tasks still appear. +- **MicroSteps and a 5-minute focus timer.** Any extracted task can be broken into 3–7 concrete sub-steps. Select a MicroStep to start a 5-minute timer scoped to that one step, so you can work on one thing at a time. +- **History with full-text search.** Every transcript is indexed and searchable. Filter by date, tag, or keyword. Open any past transcript in the editor to review or correct it; edits autosave. +- **Markdown export with YAML frontmatter.** Export any transcript as a Markdown file ready for Obsidian or any plain-text workflow. One button, native save dialog. +- **Read-only MCP server.** An optional `lumotia-mcp` binary lets Claude Desktop, Cline, Cursor, or any MCP-compatible client read your transcripts and tasks over a local stdio connection. It cannot write, edit, or delete anything. +- **First-run onboarding flow.** A short guided setup covers microphone selection, model download, and a practice recording with a pre-supplied prompt, so you know what to say. +- **Per-profile custom vocabulary.** Add domain-specific terms to a profile and Lumotia feeds them to the transcription engine as hints, reducing misrecognition of names and jargon. +- **Content tags and topic suggestions.** Lumotia suggests tags from your transcript. Promote them to your manual tag list with one click, or ignore them. +- **Keyboard-navigable throughout.** The full capture flow — record, review, extract tasks, start a timer, export — is completable without a mouse. Focus rings are visible on every interactive element. `prefers-reduced-motion` is respected app-wide. + +### Privacy + +- All transcription, cleanup, and task extraction runs on your device. No audio, transcript, or task data is sent anywhere. +- No telemetry, no analytics, no crash reports leave the machine unless you explicitly bundle one for a support request. +- An optional local activation log records when the hotkey fires. It never leaves your machine and can be disabled in Settings → Privacy. +- The optional MCP server is read-only and communicates over stdio only — no network listener, no remote access. Enabling it gives your MCP client read access to your full transcript history; treat it accordingly. +- Full disclosure: `docs/release/privacy-and-ai-use.md` (link added when that page lands). + +### Known limitations + +See `docs/release/v0.1-known-limitations.md` for the full list. Brief summary: + +- **macOS App Nap** may pause Lumotia when its window loses focus during long sessions. The protection code is in place but not yet verified on Apple Silicon hardware. +- **Linux idle inhibit is not wired.** The compositor may lock or suspend during a long dictation session. Workaround: launch with `systemd-inhibit --what=idle:sleep lumotia` or raise your system screen-lock timeout. +- **Windows sleep prevention is not yet implemented.** Set your active power plan's sleep timeout to "Never" while dictating. +- **If the local LLM hangs mid-generation**, the status indicator may stay on "Cleaning up" until you restart the app. Your transcript is preserved and the rest of the app continues to work. +- Cloud transcription (OpenAI Whisper API and equivalents) is not available in this release. +- The MCP server grants read access to your entire transcript history with no per-row permission boundary. A finer-grained permission system is planned for a later release. + +[Unreleased]: https://github.com/jakeadriansames/lumotia/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/jakeadriansames/lumotia/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..20cbefd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Lumotia — a local-first, cognitive-load-aware dictation + task-capture desktop app. Tauri 2 + Svelte 5 frontend, Rust workspace backend. Pre-alpha, daily-dogfooded on Linux/Wayland (KDE). Full product context lives in [README.md](README.md) and [docs/brief/](docs/brief/); design principles are non-negotiable and codified in [docs/whisper-ecosystem/lumotia-context.md](docs/whisper-ecosystem/lumotia-context.md). + +## Commands + +Dev launch (Vite + Tauri, with supply-chain pre-flight): +```bash +./run.sh # canonical; also: npm run dev:tauri +npm run dev:frontend # frontend-only iteration, no Tauri +``` + +Install: `npm ci --ignore-scripts` (never bare `npm install` — `--ignore-scripts` blocks the npm-worm postinstall vector). + +Tests + checks: +```bash +cargo test --workspace # all Rust tests (220+ lib, 67 Tauri-app) +cargo test -p lumotia-transcription # single crate +cargo test -p lumotia-llm # single test (substring match) +cargo check --workspace --all-targets # what CI runs +cargo fmt --check # release gate +cargo clippy --workspace --all-targets -- -D warnings # release gate +npm run check # svelte-check, jsconfig-driven +npm run test # vitest (jsdom; *.test.ts beside source) +npm run test -- src/lib/foo.test.ts # single vitest file +``` + +Release build: `npm run tauri build`. CI builds installers on tag push. + +Rebrand-migration end-to-end probe (Linux only — Tauri 2 ignores `HOME` overrides on macOS): +```bash +cargo build -p lumotia +scripts/dogfood-rebrand-drill.sh # sandbox mode +scripts/dogfood-rebrand-drill.sh --against-real-home # refuses if lumotia data exists +``` + +## Architecture (the parts to internalise before editing) + +Three layers, strict dependency direction: **Svelte (UI) → Tauri commands (OS bridge) → Rust crates (brain)**. The MCP server (`lumotia-mcp`) is a separate read-only binary that opens the same SQLite store — Lumotia-as-primitive for external agents. + +Rust workspace (`crates/*` + `src-tauri/`) holds all logic. Tauri command modules in [src-tauri/src/commands/](src-tauri/src/commands/) are thin adapters and should not contain business logic. The 22 command modules map roughly 1:1 to a subsystem (audio, llm, transcription, profiles, tasks, hotkey, paste, windows, …). The utility-only modules in the same directory (`mod`, `power`, `security`) carry no `#[tauri::command]` attribute — don't add them to the invoke handler. + +Engine abstractions: `LocalEngine` in `lumotia-transcription` wraps both Whisper (`whisper-rs`) and Parakeet (`transcribe-rs` ONNX) behind a common `Transcriber` trait. LLM surfaces (`cleanup_text`, `decompose_task`, `extract_tasks`) in `lumotia-llm` use GBNF grammars to guarantee parseable JSON. Prompt-injection-hardened cleanup prompt lives in `lumotia-ai-formatting::llm_client::CLEANUP_PROMPT`. + +Storage is SQLite via `sqlx` 0.8 in `lumotia-storage`, with FTS5 for transcript search. The MCP binary opens this store read-only. + +### Frontend state model + +Svelte 5 runes (`$state`, `$derived`, `$effect`) — no Svelte 3/4 store API. State lives in [src/lib/stores/](src/lib/stores/), one file per store. The central store is [page.svelte.ts](src/lib/stores/page.svelte.ts) — transcripts, profiles, taskLists, templates, etc. are fields on it. Secondary windows (`/float`, `/viewer`, `/preview`) use named layouts (`+layout@.svelte`) to skip the main shell and run chrome-free. + +### Wiring contracts (enforced socially, not by the compiler) + +- **Every new Tauri command** must be (a) implemented in `src-tauri/src/commands/.rs`, (b) registered in the invoke handler in [src-tauri/src/lib.rs](src-tauri/src/lib.rs), and (c) called from the frontend. Forgetting (b) is the most common breakage. +- **Every Settings-visible setting** needs a type field in [src/lib/types/app.ts](src/lib/types/app.ts) and a default in [src/lib/stores/page.svelte.ts](src/lib/stores/page.svelte.ts). +- **Every new workspace crate** needs a `description` in its `Cargo.toml`. +- Smoke test per new command or crate module; workspace floor is "no regressions on main." + +## Platform notes that affect code + +- **Wayland is a first-class target.** Don't assume X11. The preview overlay uses `WindowTypeHint::Utility`, never steals focus, is pinned across virtual desktops, and is hidden from Alt+Tab. The paste matrix (`wtype` / `xdotool` / `ydotool` on Linux, AppleScript on macOS, SendKeys on Windows) handles the focus-race against the overlay — see [src-tauri/src/commands/paste.rs](src-tauri/src/commands/paste.rs). +- **Linux hotkey is evdev**, not `tauri-plugin-global-shortcut`. Implemented in `lumotia-hotkey`. Requires the user to be in the `input` group; the crate surfaces this as a clear error when `/dev/input/event*` is inaccessible. +- **`run.sh` owns Linux rendering env vars** (`LIBCLANG_PATH`, `WEBKIT_DISABLE_DMABUF_RENDERER`, `GDK_BACKEND=x11` on Wayland to dodge a webkit2gtk issue). Anything that pre-checks these in `lib.rs` is checking what the launcher set, not the user's shell. +- **CI runs on all three OSes** (`.github/workflows/`) — Linux/macOS/Windows. macOS and Windows are CI-validated but not runtime-tested; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for tracked gaps (App Nap on Apple Silicon, idle-inhibit on X11/Linux, sleep prevention on Windows). + +## Privacy invariants (non-negotiable) + +No voice, transcript, or task data leaves the machine unless the user explicitly sends it. No telemetry, no analytics, no crash-reporting service. Cleanup is **additive** — raw Whisper transcript must always be recoverable. LLM scope is narrow: transcription cleanup + task extraction only. Not a wake-word agent, not a chat UI, not a multi-provider cloud fan-out. If a change risks any of this, surface it before implementing. + +## v0.1 release scope (active gate) + +The v0.1 ship gate lives in [docs/release/v0.1-checklist.md](docs/release/v0.1-checklist.md); the UI hardening boundary is pinned in [docs/release/v0.1-ui-hardening.md](docs/release/v0.1-ui-hardening.md). All release docs live under [docs/release/](docs/release/) — use that folder as the source of truth. Before adding a feature, check the **Out of scope for v0.1** list — reopening a v0.2-flagged item moves the ship date. Garden Inbox, full Settings 7-group regroup, OpenAI Whisper API BYOK, OEM verification, Obsidian plugin, and the Phase B filter-chain refactor are all explicitly v0.2+. + +The release acceptance test is the 10-step tester flow (install → onboarding → record → cleanup → task → MicroSteps + timer → history search). Warm activation target: first real recording within 3 minutes of opening the app. UI hardening is a *hardening* pass, not a redesign — every item must be testable, not aesthetic. + +## Where to look first + +- Latest session context: [HANDOVER.md](HANDOVER.md), then dated handovers in [docs/handovers/](docs/handovers/). +- Tracked limitations + per-step failure-mode catalogue: [KNOWN-ISSUES.md](KNOWN-ISSUES.md). +- Per-platform dependency reference: [docs/dev-setup.md](docs/dev-setup.md). +- Product/strategy framing: [docs/brief/](docs/brief/) — especially `what-lumotia-is.md`, `design-principles.md`, `target-audience.md`. +- Active workstreams + 31-item research backlog: [docs/whisper-ecosystem/brief.md](docs/whisper-ecosystem/brief.md), `workstream-A.md`, `workstream-B.md`. +- GPU tuning roadmap: [docs/gpu-tuning/plan.md](docs/gpu-tuning/plan.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3c139dd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing to Lumotia + +## Welcome + +Lumotia is a single-developer-led, AI-assisted, human-directed indie app. Jake Sames at CORBEL defines the product, sets the privacy model, writes the tests that matter, and ships through a repeatable quality gate. AI coding tools are used during implementation. Every release is dogfood-tested, every release ships a known-limitations document, and every release has an audit trail in the git log. Read `docs/release/how-lumotia-is-built.md` to understand the trust model before contributing. + +## What we are looking for in v0.1 + +Bug reports and tester feedback are the most valuable contributions right now. **New feature work is paused until v0.2** — the scope is locked per `docs/release/v0.1-checklist.md`. Reopening a v0.2-flagged item moves the ship date. If you have a feature idea, open a Discussion rather than a PR. + +## How to file a bug + +Open an issue using the **Bug report** template. The template walks you through version, platform, steps to reproduce, and expected vs actual behaviour. + +Please attach a diagnostic bundle — it speeds up triage significantly. Generate one from **Settings → Help → Generate diagnostic bundle**. The bundle never includes audio or transcript content; it contains logs, system info, and redacted preferences only. + +## How to file v0.1 tester feedback + +Open an issue using the **v0.1 Tester feedback** template. It covers the structured questions from the tester-onboarding-kit: cold-setup pass, warm-activation pass, confusion points, breakage, task-extraction quality, and whether you would use it again. + +If you followed the onboarding kit, you already have the answers. The template takes about 5 minutes to fill in. + +## How to submit a PR + +Small, focused changes only. The PR template has a checklist — run every gate green before submitting. + +Before submitting: + +- `cargo test --workspace` green +- `cargo fmt --check` clean +- `cargo clippy --workspace --all-targets -- -D warnings` clean +- `npm run check` 0 errors / 0 warnings +- `npm run test` green +- `scripts/dogfood-rebrand-drill.sh` 8/8 (if you touched migration or data-directory logic) +- Manual UI walk-through (if you touched the frontend) + +**Architectural changes require a Discussion first.** Opening a PR that restructures crates, reorganises commands, or changes the storage schema without prior alignment wastes both our time. + +## Local dev setup + +```bash +npm ci --ignore-scripts # never bare npm install — --ignore-scripts blocks postinstall vectors +./run.sh # canonical dev launch (Vite + Tauri, with supply-chain pre-flight) +npm run dev:frontend # frontend-only iteration, no Tauri +``` + +Per-platform dependencies (WebKit, LLVM, Rust toolchain, evdev headers) are documented in `docs/dev-setup.md`. The Rust toolchain is pinned in `rust-toolchain.toml` — you do not need to manage it manually. + +## Code of conduct + +This is a calm, professional project. No harassment, no personal attacks, no bad-faith contributions. The maintainer is one person — please be patient on response times. Issues and PRs that are rude or dismissive will be closed without comment. + +## Licence + +Lumotia is licensed under **AGPL-3.0-or-later**. By submitting a pull request, you agree that your contribution is offered under the same licence. If that does not work for you, please say so before putting in the work. diff --git a/Cargo.lock b/Cargo.lock index 62c007a..50f3089 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -376,6 +387,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "block2" version = "0.6.2" @@ -452,6 +473,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -591,6 +621,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +dependencies = [ + "crypto-common 0.2.1", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -620,6 +660,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "combine" version = "4.6.7" @@ -639,6 +685,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.4.0" @@ -739,6 +797,12 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -748,6 +812,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -812,6 +885,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.29.6" @@ -862,6 +944,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -937,6 +1028,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + [[package]] name = "der" version = "0.8.0" @@ -1028,8 +1125,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", + "zeroize", ] [[package]] @@ -1408,6 +1518,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1790,10 +1901,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2029,6 +2142,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hmac-sha256" version = "1.1.14" @@ -2108,6 +2230,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2382,6 +2513,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -2642,6 +2782,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fc329e1457d97a9d58a4e2ca49e3be572431a7e096008efc2e3a3c19d428f4" + [[package]] name = "libc" version = "0.2.186" @@ -2752,12 +2898,189 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lumotia" +version = "0.1.0" +dependencies = [ + "arboard", + "base64 0.22.1", + "gdk", + "gtk", + "lumotia-ai-formatting", + "lumotia-audio", + "lumotia-cloud-providers", + "lumotia-core", + "lumotia-hotkey", + "lumotia-llm", + "lumotia-storage", + "lumotia-transcription", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "sqlx", + "tauri", + "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-window-state", + "tempfile", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", + "webkit2gtk", + "windows 0.62.2", + "zbus", + "zip", +] + +[[package]] +name = "lumotia-ai-formatting" +version = "0.1.0" +dependencies = [ + "lumotia-core", + "lumotia-llm", + "regex-lite", + "tracing", +] + +[[package]] +name = "lumotia-audio" +version = "0.1.0" +dependencies = [ + "cpal", + "hound", + "lumotia-core", + "regex", + "rubato", + "serde", + "symphonia", + "tokio", + "tracing", +] + +[[package]] +name = "lumotia-cloud-providers" +version = "0.1.0" +dependencies = [ + "async-trait", + "lumotia-core", + "serde", +] + +[[package]] +name = "lumotia-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "libloading 0.8.9", + "num_cpus", + "serde", + "serde_json", + "sysinfo", + "tempfile", + "thiserror 2.0.18", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lumotia-hotkey" +version = "0.1.0" +dependencies = [ + "evdev", + "lumotia-core", + "nix 0.29.0", + "notify", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "lumotia-llm" +version = "0.1.0" +dependencies = [ + "encoding_rs", + "futures-util", + "llama-cpp-2", + "lumotia-core", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "lumotia-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "lumotia-storage", + "serde", + "serde_json", + "sqlx", + "tempfile", + "tokio", +] + +[[package]] +name = "lumotia-storage" +version = "0.1.0" +dependencies = [ + "lumotia-core", + "serde", + "sqlx", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "lumotia-transcription" +version = "0.1.0" +dependencies = [ + "async-trait", + "futures-util", + "lumotia-cloud-providers", + "lumotia-core", + "num_cpus", + "reqwest 0.12.28", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "transcribe-rs", + "whisper-rs", +] + [[package]] name = "lzma-rust2" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" +[[package]] +name = "lzma-rust2" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" +dependencies = [ + "sha2", +] + [[package]] name = "mac" version = "0.1.1" @@ -2785,169 +3108,6 @@ dependencies = [ "libc", ] -[[package]] -name = "magnotia" -version = "0.1.0" -dependencies = [ - "arboard", - "base64 0.22.1", - "gdk", - "gtk", - "magnotia-ai-formatting", - "magnotia-audio", - "magnotia-cloud-providers", - "magnotia-core", - "magnotia-hotkey", - "magnotia-llm", - "magnotia-storage", - "magnotia-transcription", - "objc2", - "objc2-foundation", - "serde", - "serde_json", - "sqlx", - "tauri", - "tauri-build", - "tauri-plugin-autostart", - "tauri-plugin-dialog", - "tauri-plugin-global-shortcut", - "tauri-plugin-notification", - "tauri-plugin-opener", - "tauri-plugin-window-state", - "tempfile", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", - "webkit2gtk", -] - -[[package]] -name = "magnotia-ai-formatting" -version = "0.1.0" -dependencies = [ - "magnotia-core", - "magnotia-llm", - "regex-lite", - "tracing", -] - -[[package]] -name = "magnotia-audio" -version = "0.1.0" -dependencies = [ - "cpal", - "hound", - "magnotia-core", - "regex", - "rubato", - "serde", - "symphonia", - "tokio", - "tracing", -] - -[[package]] -name = "magnotia-cloud-providers" -version = "0.1.0" -dependencies = [ - "async-trait", - "magnotia-core", - "serde", -] - -[[package]] -name = "magnotia-core" -version = "0.1.0" -dependencies = [ - "async-trait", - "libloading 0.8.9", - "num_cpus", - "serde", - "serde_json", - "sysinfo", - "tempfile", - "thiserror 2.0.18", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "magnotia-hotkey" -version = "0.1.0" -dependencies = [ - "evdev", - "magnotia-core", - "nix 0.29.0", - "notify", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "magnotia-llm" -version = "0.1.0" -dependencies = [ - "encoding_rs", - "futures-util", - "llama-cpp-2", - "magnotia-core", - "reqwest 0.12.28", - "serde", - "serde_json", - "sha2", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "magnotia-mcp" -version = "0.1.0" -dependencies = [ - "anyhow", - "magnotia-storage", - "serde", - "serde_json", - "sqlx", - "tempfile", - "tokio", -] - -[[package]] -name = "magnotia-storage" -version = "0.1.0" -dependencies = [ - "log", - "magnotia-core", - "serde", - "sqlx", - "thiserror 1.0.69", - "tokio", - "uuid", -] - -[[package]] -name = "magnotia-transcription" -version = "0.1.0" -dependencies = [ - "async-trait", - "futures-util", - "magnotia-cloud-providers", - "magnotia-core", - "num_cpus", - "reqwest 0.12.28", - "sha2", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tracing", - "transcribe-rs", - "whisper-rs", -] - [[package]] name = "markup5ever" version = "0.14.1" @@ -3626,7 +3786,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" dependencies = [ "hmac-sha256", - "lzma-rust2", + "lzma-rust2 0.15.7", "ureq", ] @@ -3690,6 +3850,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -4004,6 +4174,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4982,6 +5158,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4989,8 +5176,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -5314,6 +5501,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "symphonia" version = "0.5.5" @@ -6100,6 +6293,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -6370,6 +6564,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -6479,6 +6686,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -7911,12 +8124,85 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "aes", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.2", + "hmac", + "indexmap 2.14.0", + "lzma-rust2 0.16.2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 47891e2..3dcb993 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,12 @@ members = ["src-tauri", "crates/*"] resolver = "2" +[workspace.package] +version = "0.1.0" +edition = "2021" +repository = "https://github.com/jakeadriansames/lumotia" +license = "AGPL-3.0-or-later" + [profile.release] codegen-units = 1 lto = "thin" diff --git a/HANDOVER.md b/HANDOVER.md index ce9718b..f6648a6 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -1,11 +1,11 @@ --- name: handover-2026-04-25 type: reference -tags: [handover, session, magnotia, phase-9, polish-debt] +tags: [handover, session, lumotia, phase-9, polish-debt] description: Session handover — 2026/04/24-25 Phase 9 polish debt mostly shipped --- -# Magnotia Handover — 2026/04/25 +# Lumotia Handover — 2026/04/25 > **Note:** Session-specific handover. Migration v14 (`transcripts.llm_tags`) was the head **at the time of this session**. Subsequent work has advanced the schema; current head is v15 (`idx_transcripts_profile_created`, composite index). For the current codebase shape, see [`docs/architecture-map/`](docs/architecture-map/) and [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md). @@ -13,25 +13,25 @@ Phase 9 session. Spec + plan written from scratch and committed; plan correction ## Rebrand note -Product rename **Magnotia → Magnotia** still in flight. Copy in new docs is "Magnotia"; codebase paths / package names / repos still carry `magnotia`. No rebrand work this session. See `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_magnotia_rebrand.md`. +Product rename **Magnotia → Lumotia** completed 2026/05/13 (cascade across both repos, all 15 phases, cynical-QC gated). Codebase paths, package names, crate names, bundle identifier (`consulting.corbel.lumotia`), localStorage keys, settings keys, event channels, and on-disk data dir all carry `lumotia`. Migration shims preserve existing user data (data-dir rename, settings-key rename, localStorage-key rename). Remote repo rename pending Jake's action. See `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_lumotia_rebrand_cascade_complete.md` (write pending Phase 15.5 of the cascade plan). ## What shipped this session ### 9a — Export plumbing -- `write_text_file_cmd` Rust command in new `src-tauri/src/commands/fs.rs`, with two unit tests (UTF-8 round-trip + bad-parent error path). Registered in `invoke_handler!`. `tempfile = "3"` added as `[dev-dependencies]` on the magnotia crate. +- `write_text_file_cmd` Rust command in new `src-tauri/src/commands/fs.rs`, with two unit tests (UTF-8 round-trip + bad-parent error path). Registered in `invoke_handler!`. `tempfile = "3"` added as `[dev-dependencies]` on the lumotia crate. - `src/lib/utils/saveMarkdown.ts` utility centralises `suggestedFilename`, `saveTranscriptAsMarkdown`, `exportTranscriptsToDir` (directory-mode bulk export with in-batch collision suffixing). - HistoryPage `exportMarkdown` no longer copies to clipboard; it opens the OS save dialog and writes the file. Cancel returns silently. - HistoryPage gained a slim leading checkbox per row, a bulk-action toolbar (select-all / clear / export / delete), `Esc` to clear, `Cmd/Ctrl+A` to select-all-visible when focus is inside the list and not in a text input. ### 9b — LLM content tags -- `magnotia-llm` exports a new `ContentTags { topic, intent }`, an `INTENT_CLOSED_SET`, an `is_valid_intent` helper, a `CONTENT_TAGS_SYSTEM` prompt and a `CONTENT_TAGS_GRAMMAR` GBNF (recursive style matching the existing `TASK_ARRAY_GRAMMAR`). -- `LlmEngine::extract_content_tags` method follows the same render-chat → generate → JSON-parse shape as the existing `cleanup_text` and `extract_tasks`. Truncates to the trailing 2000 chars on a UTF-8 boundary; max_tokens 96 is enough for the JSON envelope. Smoke test in `crates/llm/tests/content_tags_smoke.rs` is gated on `MAGNOTIA_LLM_TEST_MODEL` matching the Phase 8 pattern. +- `lumotia-llm` exports a new `ContentTags { topic, intent }`, an `INTENT_CLOSED_SET`, an `is_valid_intent` helper, a `CONTENT_TAGS_SYSTEM` prompt and a `CONTENT_TAGS_GRAMMAR` GBNF (recursive style matching the existing `TASK_ARRAY_GRAMMAR`). +- `LlmEngine::extract_content_tags` method follows the same render-chat → generate → JSON-parse shape as the existing `cleanup_text` and `extract_tasks`. Truncates to the trailing 2000 chars on a UTF-8 boundary; max_tokens 96 is enough for the JSON envelope. Smoke test in `crates/llm/tests/content_tags_smoke.rs` is gated on `LUMOTIA_LLM_TEST_MODEL` matching the Phase 8 pattern. - `extract_content_tags_cmd` Tauri wrapper bridges through `state.llm_engine` with the standard `spawn_blocking` + `PowerAssertion` guard. ### 9b structural — migration v14 + persistence wiring A correction layered in after the critical-review pass discovered the original Task 9 was assuming a writable `saveHistory()` path that turned out to be a no-op stub. - Migration v14 adds `transcripts.llm_tags TEXT NOT NULL DEFAULT ''`. -- `magnotia-storage` `database.rs` SELECT statements include the column. `TranscriptRow` + `transcript_row_from` carry it. `update_transcript_meta` accepts an `Option<&str>` for `llm_tags` (sixth optional, `#[allow(too_many_arguments)]` keeps clippy happy without inverting the signature into a struct). +- `lumotia-storage` `database.rs` SELECT statements include the column. `TranscriptRow` + `transcript_row_from` carry it. `update_transcript_meta` accepts an `Option<&str>` for `llm_tags` (sixth optional, `#[allow(too_many_arguments)]` keeps clippy happy without inverting the signature into a struct). - `commands/transcripts.rs` `TranscriptDto` + `UpdateTranscriptMetaRequest` add `llm_tags`; `update_transcript_meta_cmd` forwards. - Frontend types: `TranscriptEntry.llmTags: string[]`, `TranscriptRow.llmTags: string`, `ContentTags`, optional `TranscriptMetaPatch.llmTags`. - `mapTranscriptRow` hydrates `llmTags`. `saveTranscriptMeta` now also forwards `llmTags` payloads. `buildFrontmatter` unions auto + manual + LLM tags into the exported markdown frontmatter. @@ -56,7 +56,7 @@ Fresh run on `main` tip `dd45f10`: - `cargo fmt --check`: clean. - `cargo clippy --all-targets -- -D warnings`: clean. -- `cargo test`: **277 tests pass**, 0 failed. Storage gained 1 new test (`update_transcript_meta_writes_llm_tags`), magnotia-tauri gained 2 (write_text_file). The Phase 8 brittle test fix is in this count. +- `cargo test`: **277 tests pass**, 0 failed. Storage gained 1 new test (`update_transcript_meta_writes_llm_tags`), lumotia-tauri gained 2 (write_text_file). The Phase 8 brittle test fix is in this count. - `npm run check`: 0 errors, 0 warnings across 3957 files. - `npm run build`: clean production build via `@sveltejs/adapter-static`. @@ -64,13 +64,13 @@ Fresh run on `main` tip `dd45f10`: The original Phase 9 spec + plan committed at `49a795f` + `48d3db7` had three mismatches against the actual codebase, surfaced by a critical-review pass before execution. Layered as a corrections appendix in commit `3eb24f2`: -1. `magnotia-llm` is `LlmEngine::generate(prompt, config)` synchronous, not the speculated `LlamaEngine::generate_chat(messages, config).await`. +1. `lumotia-llm` is `LlmEngine::generate(prompt, config)` synchronous, not the speculated `LlamaEngine::generate_chat(messages, config).await`. 2. `AppState.llm_engine: Arc` is direct, not behind a `RwLock`. 3. **Structural** — `transcripts.llm_tags` requires a real SQLite migration plus Tauri command extension because the frontend `saveHistory()` is a no-op stub. Original plan assumed `manualTags`-mirroring would suffice. Migration v14 + `update_transcript_meta` extension landed as a new task to cover this. Picked up the latent `manualTags` persistence bug for free. ## Owed to Jake (next session) -1. **Manual dogfood walkthrough.** Cannot be driven by an automated agent. When opening Magnotia next: +1. **Manual dogfood walkthrough.** Cannot be driven by an automated agent. When opening Lumotia next: - Export one transcript via the History "Export .md" button — save dialog opens, file written to chosen path. Cancel — no toast, no fallback. - Select 3 history rows via checkboxes — toolbar surfaces, "Export selected" writes one .md per row to a chosen folder, collisions suffixed " (2)" etc. - Click "Tag" on one row — within a few seconds, dashed `topic:*` and `intent:*` chips appear. Click a chip — it moves into `manualTags` (solid accent chip). Page refresh — both `manualTags` and `llmTags` survive (this is the persistence-fix outcome). @@ -92,7 +92,7 @@ The original Phase 9 spec + plan committed at `49a795f` + `48d3db7` had three mi | Phases 1-8 | All shipped. | | Phase 9 | **Mostly shipped this session.** Export plumbing, LLM content tags (with persistence), polish on sparkline + badge are live. SettingsPage deeper restructure + walkthrough a11y sweeps deferred. Roadmap entry updated. | | Phase 10a | QC: dogfood walkthrough (above), Rachmann's RB-08 Mac verification (parallel), cross-platform CI, a11y regression, clean-install test. Half day. | -| Phase 10b | Magnotia → Magnotia rename sweep: package name, all 10 crates, bundle ids, install paths, `magnotia.db` → `magnotia.db`, event names, repo rename on both remotes. Half to 1 day. | +| Phase 10b | Magnotia → Lumotia rename sweep: **COMPLETE 2026/05/13** (cascade across both repos, 15 phases). Remote repo rename pending Jake. | | Phase 10c | Release: 0.1.0 version sync, CHANGELOG seeded from roadmap phases, release notes, tag + push. Half day. | ### Release-blocker state @@ -111,8 +111,8 @@ The original Phase 9 spec + plan committed at `49a795f` + `48d3db7` had three mi - Spec: [docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md](docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md) - Plan: [docs/superpowers/plans/2026-04-24-phase9-polish-debt.md](docs/superpowers/plans/2026-04-24-phase9-polish-debt.md) -- Roadmap: [docs/roadmap/2026-04-23-magnotia-feature-complete-roadmap.md](docs/roadmap/2026-04-23-magnotia-feature-complete-roadmap.md) +- Roadmap: [docs/roadmap/2026-04-23-lumotia-feature-complete-roadmap.md](docs/roadmap/2026-04-23-lumotia-feature-complete-roadmap.md) - Previous handover: [HANDOVER-2026-04-24.md](HANDOVER-2026-04-24.md) (Phase 8) - Release-blocker index: [docs/issues/README.md](docs/issues/README.md) -- Rebrand memory: `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_magnotia_rebrand.md` +- Rebrand memory: `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_lumotia_rebrand_cascade_complete.md` - Active-focus upstream: `context/active-focus.md` in CORBEL-Main diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 71a76e7..a1c18e5 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -14,29 +14,34 @@ Tracked limitations and partial implementations in the current codebase. Each en **Workaround:** Keep the app window focused, or move the cursor periodically. For testing, App Nap can be disabled globally with `defaults write NSGlobalDomain NSAppSleepDisabled -bool YES` (revert with `-bool NO`). -### KI-02 — Linux power assertion is a no-op +### KI-02 — Linux idle inhibit ✓ fixed in v0.1 -**Status:** `PowerAssertion::begin` does nothing on Linux. The planned implementation (systemd-logind / GNOME idle inhibitor via `org.freedesktop.login1.Inhibit`) is described in the file-level doc but not wired up. +**Status:** Resolved. `acquire_idle_inhibit` now calls `org.freedesktop.login1.Manager.Inhibit` +via zbus (blocking, offloaded to `spawn_blocking`) on recording start, holding the returned file +descriptor. `release_idle_inhibit` closes the fd on recording stop, which atomically releases the +lock. The inhibit scope is `idle:sleep:handle-lid-switch` in `block` mode. -**Source:** [`src-tauri/src/commands/power.rs`](src-tauri/src/commands/power.rs). +If D-Bus is unavailable (non-systemd containers, exotic distros), the call fails gracefully with a +`tracing::warn!` and recording continues — the workaround below remains valid in those edge cases. -**Impact:** Long sessions can be paused by the compositor's idle hooks (screen lock, suspend timers) on KDE, GNOME, Hyprland, Sway, etc. +**Source:** [`src-tauri/src/commands/power.rs`](src-tauri/src/commands/power.rs) (`acquire_idle_inhibit`, `release_idle_inhibit`, `linux_inhibit` mod). -**Workaround:** Raise the system idle / screen-lock timeout while dictating, or wrap launch with `systemd-inhibit --what=idle:sleep:handle-lid-switch ./run.sh`. +**Workaround (edge cases only):** Wrap launch with `systemd-inhibit --what=idle:sleep:handle-lid-switch ./run.sh` if logind is not available. -### KI-03 — Windows power assertion is a no-op +### KI-03 — Windows sleep prevention ✓ fixed in v0.1 -**Status:** `PowerAssertion::begin` does nothing on Windows. The planned implementation (`SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_AWAYMODE_REQUIRED)` on begin, `ES_CONTINUOUS` alone on end) is described in the file-level doc but not wired up. +**Status:** Resolved. `acquire_idle_inhibit` now calls +`SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` on recording start. +`release_idle_inhibit` calls `SetThreadExecutionState(ES_CONTINUOUS)` to restore normal behaviour. +Display sleep is intentionally NOT blocked — the user is dictating, not watching the screen. -**Source:** [`src-tauri/src/commands/power.rs`](src-tauri/src/commands/power.rs). +**Source:** [`src-tauri/src/commands/power.rs`](src-tauri/src/commands/power.rs) (`acquire_idle_inhibit`, `release_idle_inhibit`, `windows_inhibit` mod). -**Impact:** Long sessions can be paused by Windows sleep policies. - -**Workaround:** Set the active power plan's sleep to "Never" while dictating. +**Workaround (edge cases only):** If the power plan has a policy override that blocks `SetThreadExecutionState`, set the active sleep timeout to "Never" while dictating. ## Cloud providers -### KI-04 — `magnotia-cloud-providers` crate is not user-exposed +### KI-04 — `lumotia-cloud-providers` crate is not user-exposed **Status:** The crate is a declared workspace dependency in [`src-tauri/Cargo.toml:36`](src-tauri/Cargo.toml#L36) and compiles into the binary, but no Tauri command, page, or settings field invokes `store_api_key` / `retrieve_api_key`. There is no UI to enter or store a cloud API key in the current build. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 35cf733..aaf3227 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Lumotia is a local-first, cognitive-load-aware dictation and task-capture deskto ## Status -**Pre-alpha.** Actively dogfooded on Linux (KDE Plasma 6 on Wayland). macOS and Windows targets are in scope and exercised by CI, but not yet beta-ready. One primary user; open source-intent with licence TBD before public beta. +**Status: v0.1 release candidate.** See [docs/release/](docs/release/) for the ship checklist + known limitations. Actively dogfooded on Linux (KDE Plasma 6 on Wayland). macOS and Windows targets are in scope and exercised by CI, but not yet beta-ready. - Current `main`: see commit log - 9 library crates plus the Tauri app crate; 220+ lib tests plus 67 Tauri-app tests, all passing @@ -25,7 +25,7 @@ Lumotia is a local-first, cognitive-load-aware dictation and task-capture deskto 4. **LLM scope is narrow.** The in-app LLM does transcription cleanup and task extraction. It is not a wake-word agent, not a chat UI, not a multi-provider cloud fan-out. 5. **Raw transcript is always recoverable.** Cleanup is additive, never destructive. The user can always see and revert to what Whisper heard. -These are enforced in the codebase (where practical) and in the docs under [`docs/whisper-ecosystem/Lumotia-context.md`](docs/whisper-ecosystem/Lumotia-context.md). +These are enforced in the codebase (where practical) and in the docs under [`docs/whisper-ecosystem/lumotia-context.md`](docs/whisper-ecosystem/lumotia-context.md). --- @@ -269,6 +269,16 @@ choco install cmake llvm vulkan-sdk See [`docs/dev-setup.md`](docs/dev-setup.md) for the authoritative per-platform dependency list and for how `LIBCLANG_PATH` should be set. +### Installing npm dependencies + +Use `npm ci --ignore-scripts` rather than bare `npm install`. `--ignore-scripts` blocks the postinstall script vector that npm-worm attacks (Shai-Hulud, mini-Shai-Hulud) rely on. `ci` installs strictly from `package-lock.json`, refusing to mutate the lockfile silently. + +```bash +npm ci --ignore-scripts +``` + +`run.sh` runs `npm audit signatures` automatically whenever `package-lock.json` is newer than the last successful audit, and refuses to launch on signature mismatch. Skip with `LUMOTIA_SKIP_AUDIT=1` for offline dev. + ### Dev launch Canonical full-stack dev launch — starts Vite, waits for port 1420, then launches Tauri: @@ -300,11 +310,36 @@ CI also builds release installers on tag push (see `.github/workflows/build.yml` ### Testing ```bash -cargo test --workspace --lib # 220+ lib tests across 9 library crates +cargo test --workspace # all Rust tests (lib + integration) npm run check # svelte-check (type-checks .svelte files) +npm run test # vitest run (frontend unit tests) +npm run test:watch # vitest watch mode cargo check --workspace --all-targets ``` +Frontend test files live alongside source (`src/**/*.test.ts`) and run in +jsdom by default. See [vite.config.js](vite.config.js) for the vitest +configuration. + +#### Rebrand-migration dogfood drill + +End-to-end probe that launches the real `target/debug/lumotia` binary +against synthetic legacy magnotia state planted on disk, then verifies +both migration paths produced the expected on-disk outcome. + +```bash +cargo build -p lumotia # need the binary first +scripts/dogfood-rebrand-drill.sh # sandbox mode (Linux only) +scripts/dogfood-rebrand-drill.sh --keep # leave sandbox dir for inspection +scripts/dogfood-rebrand-drill.sh --against-real-home # run against real $HOME +``` + +Sandbox mode is faithful only on Linux — Tauri 2 on macOS uses +`NSSearchPathForDirectoriesInDomains` which ignores `HOME` overrides. The +drill refuses to start in sandbox mode on macOS. Real-home mode refuses +to start if any lumotia data already exists at your real paths, so it +can roll back cleanly on exit. + --- ## Project documentation @@ -380,7 +415,13 @@ Pre-alpha status; contribution process TBD before public beta. For now: ## Licence -To be finalised before public beta. Current intent: MIT or similar permissive licence, with Corbel Consulting offering optional commercial support / managed services as the revenue path. +AGPL-3.0-or-later. See [LICENSE](LICENSE) for the full text. The implementation is AI-assisted; the trust + audit framing is in [docs/release/how-lumotia-is-built.md](docs/release/how-lumotia-is-built.md). + +--- + +## Reporting issues + +File issues at https://github.com/jakeadriansames/lumotia/issues — please include your platform, the Lumotia version (Settings → About), what you did, what you expected, and what actually happened. Crash dumps live at `/crashes/`; attaching the most recent one helps a lot. --- diff --git a/crates/ai-formatting/Cargo.toml b/crates/ai-formatting/Cargo.toml index 0d0a1a3..908f332 100644 --- a/crates/ai-formatting/Cargo.toml +++ b/crates/ai-formatting/Cargo.toml @@ -1,11 +1,13 @@ [package] -name = "magnotia-ai-formatting" -version = "0.1.0" -edition = "2021" -description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Magnotia" +name = "lumotia-ai-formatting" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Lumotia" [dependencies] -magnotia-core = { path = "../core" } -magnotia-llm = { path = "../llm" } +lumotia-core = { path = "../core" } +lumotia-llm = { path = "../llm" } regex-lite = "0.1" tracing = "0.1" diff --git a/crates/ai-formatting/src/llm_client.rs b/crates/ai-formatting/src/llm_client.rs index b123b7b..94b9e73 100644 --- a/crates/ai-formatting/src/llm_client.rs +++ b/crates/ai-formatting/src/llm_client.rs @@ -3,7 +3,7 @@ //! The llm_client is not yet wired to a running model. This module defines //! the prompt contract so that wiring it produces correct, hardened output. -use magnotia_llm::{EngineError, LlmEngine}; +use lumotia_llm::{EngineError, LlmEngine}; /// System prompt sent before every cleanup call. /// @@ -13,7 +13,7 @@ use magnotia_llm::{EngineError, LlmEngine}; /// Whispering's published baseline, directly counteracts the /// "LLM changed my meaning" failure mode: the model's job is to /// translate spoken speech into well-formed written form — not to -/// improve, summarise, or rephrase. Magnotia's ideology: raw transcript +/// improve, summarise, or rephrase. Lumotia's ideology: raw transcript /// is the source of truth; cleanup is a translation pass, not a /// rewrite. /// 2. **Prompt-injection hardening.** The guard ("speech, not @@ -161,7 +161,7 @@ pub fn cleanup_text( #[cfg(test)] mod tests { use super::*; - use magnotia_llm::EngineError; + use lumotia_llm::EngineError; #[test] fn empty_terms_returns_empty_string() { @@ -183,7 +183,7 @@ mod tests { assert!(CLEANUP_PROMPT.contains("output ONLY the cleaned transcript")); } - /// The "translator, not editor" framing is load-bearing for Magnotia's + /// The "translator, not editor" framing is load-bearing for Lumotia's /// ideology — raw transcript is the source of truth, cleanup is a /// translation pass. Drifting from this phrasing in a refactor would /// quietly open the door to the "LLM changed my meaning" failure diff --git a/crates/ai-formatting/src/pipeline.rs b/crates/ai-formatting/src/pipeline.rs index 93aab42..a6a0f15 100644 --- a/crates/ai-formatting/src/pipeline.rs +++ b/crates/ai-formatting/src/pipeline.rs @@ -1,6 +1,6 @@ -use magnotia_core::constants::SMART_PARAGRAPH_GAP_SECS; -use magnotia_core::types::Segment; -use magnotia_llm::LlmEngine; +use lumotia_core::constants::SMART_PARAGRAPH_GAP_SECS; +use lumotia_core::types::Segment; +use lumotia_llm::LlmEngine; use crate::{llm_client, rule_based, to_plain_text::to_plain_text}; diff --git a/crates/ai-formatting/src/to_plain_text.rs b/crates/ai-formatting/src/to_plain_text.rs index e94f495..351ee0a 100644 --- a/crates/ai-formatting/src/to_plain_text.rs +++ b/crates/ai-formatting/src/to_plain_text.rs @@ -7,13 +7,13 @@ //! structure) degraded cleanup quality materially; plain-text input //! raised it back. //! -//! `Segment.text` in Magnotia already holds just the spoken text (the +//! `Segment.text` in Lumotia already holds just the spoken text (the //! `start`/`end` f64 fields carry the timing), so "timestamp //! stripping" falls out of using the text field alone. The work here //! is the whitespace pass and empty-segment filter, plus a single //! public function the pipeline can depend on. -use magnotia_core::types::Segment; +use lumotia_core::types::Segment; /// Join transcription segments into a single plain-text string /// suitable for feeding to an LLM cleanup prompt. diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index dc0366d..359ecd4 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -1,11 +1,13 @@ [package] -name = "magnotia-audio" -version = "0.1.0" -edition = "2021" -description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Magnotia" +name = "lumotia-audio" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Lumotia" [dependencies] -magnotia-core = { path = "../core" } +lumotia-core = { path = "../core" } # Microphone capture cpal = "0.17" diff --git a/crates/audio/src/capture.rs b/crates/audio/src/capture.rs index b545368..75a69ad 100644 --- a/crates/audio/src/capture.rs +++ b/crates/audio/src/capture.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use std::sync::Arc; @@ -8,7 +9,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use std::sync::OnceLock; -use magnotia_core::error::{MagnotiaError, Result}; +use lumotia_core::error::{Error, Result}; const AUDIO_CHANNEL_CAPACITY: usize = 32; @@ -106,7 +107,7 @@ impl MicrophoneCapture { let devices = host .input_devices() - .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))?; + .map_err(|e| Error::AudioCaptureFailed(format!("input_devices: {e}")))?; // Load ALSA card descriptions once per enumeration. These are the // "real" product names (e.g. "Blue Microphones") that cpal's @@ -140,21 +141,32 @@ impl MicrophoneCapture { /// Start capturing from the device whose name matches `device_name` exactly. /// If no match is found, returns an error rather than silently falling back. - pub fn start_with_device(device_name: &str) -> Result<(Self, mpsc::Receiver)> { + /// + /// The returned tuple is `(capture, replay_buffer, rx)`: + /// - `replay_buffer` holds chunks observed during the 350ms + /// validation pre-roll. Consumers MUST drain it before reading + /// from `rx` so the head of the recording isn't lost on hosts + /// whose cpal buffer is small enough to overflow the 32-slot + /// channel during validation (WASAPI exclusive, low-latency + /// ALSA at 256 frames). + /// - `rx` is the live cpal callback channel. + pub fn start_with_device( + device_name: &str, + ) -> Result<(Self, VecDeque, mpsc::Receiver)> { let host = cpal::default_host(); let devices = host .input_devices() - .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))?; + .map_err(|e| Error::AudioCaptureFailed(format!("input_devices: {e}")))?; for device in devices { let name = device_display_name(&device).unwrap_or_default(); if name == device_name { - tracing::info!(target: "magnotia_audio", "start_with_device: opening explicit device '{name}'"); + tracing::info!(target: "lumotia_audio", "start_with_device: opening explicit device '{name}'"); return open_and_validate(device, &name, /* require_audio = */ true); } } - Err(MagnotiaError::AudioCaptureFailed(format!( + Err(Error::AudioCaptureFailed(format!( "Selected device '{device_name}' not found in current host enumeration. \ It may have been disconnected. Open Settings → Audio to pick another." ))) @@ -169,7 +181,7 @@ impl MicrophoneCapture { /// a short window — this is what defeats the "silent monitor source wins" bug. /// 4. If no non-monitor device produces real audio, fall back to monitor sources /// as a last resort (with a clear log line). Never accept dead silence. - pub fn start() -> Result<(Self, mpsc::Receiver)> { + pub fn start() -> Result<(Self, VecDeque, mpsc::Receiver)> { let host = cpal::default_host(); let default_name = host .default_input_device() @@ -178,7 +190,7 @@ impl MicrophoneCapture { let mut all_devices: Vec = host .input_devices() - .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))? + .map_err(|e| Error::AudioCaptureFailed(format!("input_devices: {e}")))? .collect(); // Sort: default first, then non-monitor, then monitor-as-last-resort. @@ -196,7 +208,7 @@ impl MicrophoneCapture { }); tracing::info!( - target: "magnotia_audio", + target: "lumotia_audio", device_count = all_devices.len(), default = %default_name, "enumerated input devices" @@ -211,7 +223,7 @@ impl MicrophoneCapture { match open_and_validate(device.clone(), &name, true) { Ok(result) => return Ok(result), Err(e) => { - tracing::warn!(target: "magnotia_audio", device = %name, error = %e, "candidate device rejected"); + tracing::warn!(target: "lumotia_audio", device = %name, error = %e, "candidate device rejected"); } } } @@ -219,7 +231,7 @@ impl MicrophoneCapture { // Second pass: accept anything that delivers bytes (monitor sources // included). Better to capture from a monitor than fail entirely. tracing::warn!( - target: "magnotia_audio", + target: "lumotia_audio", "no non-monitor mic produced audio; falling back to monitor/loopback sources" ); for device in &all_devices { @@ -227,7 +239,7 @@ impl MicrophoneCapture { match open_and_validate(device.clone(), &name, false) { Ok(result) => { tracing::warn!( - target: "magnotia_audio", + target: "lumotia_audio", device = %name, "capturing from likely monitor source; recordings may be silent or contain system audio" ); @@ -237,7 +249,7 @@ impl MicrophoneCapture { } } - Err(MagnotiaError::AudioCaptureFailed( + Err(Error::AudioCaptureFailed( "No working microphone found. Check that an input device is connected, \ that PulseAudio/PipeWire is running, and that the app has microphone permission. \ Then open Settings → Audio to pick a device explicitly." @@ -358,16 +370,20 @@ fn open_and_validate( device: cpal::Device, name: &str, require_audio: bool, -) -> Result<(MicrophoneCapture, mpsc::Receiver)> { +) -> Result<( + MicrophoneCapture, + VecDeque, + mpsc::Receiver, +)> { let config = device .default_input_config() - .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("default_input_config: {e}")))?; + .map_err(|e| Error::AudioCaptureFailed(format!("default_input_config: {e}")))?; let sample_rate = config.sample_rate(); let channels = config.channels(); let format = config.sample_format(); tracing::info!( - target: "magnotia_audio", + target: "lumotia_audio", device = %name, sample_rate, channels, @@ -376,7 +392,6 @@ fn open_and_validate( ); let (tx, rx) = mpsc::sync_channel::(AUDIO_CHANNEL_CAPACITY); - let requeue_tx = tx.clone(); let dropped_chunks = Arc::new(AtomicU64::new(0)); // Bounded channel for runtime stream errors. Capacity 32 = plenty for // the rare error case; if it ever fills, drops are reported via stderr @@ -422,16 +437,16 @@ fn open_and_validate( name.to_string(), ), other => { - return Err(MagnotiaError::AudioCaptureFailed(format!( + return Err(Error::AudioCaptureFailed(format!( "unsupported sample format {other:?}" ))) } } - .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("build_input_stream: {e}")))?; + .map_err(|e| Error::AudioCaptureFailed(format!("build_input_stream: {e}")))?; stream .play() - .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("stream.play: {e}")))?; + .map_err(|e| Error::AudioCaptureFailed(format!("stream.play: {e}")))?; // Validation window: collect chunks for DEVICE_VALIDATION_MS, compute RMS. let deadline = @@ -460,14 +475,14 @@ fn open_and_validate( } if total_samples == 0 { - return Err(MagnotiaError::AudioCaptureFailed( + return Err(Error::AudioCaptureFailed( "device delivered zero samples in validation window".into(), )); } let rms = (sum_sq / total_samples as f64).sqrt() as f32; tracing::info!( - target: "magnotia_audio", + target: "lumotia_audio", device = %name, samples = total_samples, rms, @@ -475,7 +490,7 @@ fn open_and_validate( ); if require_audio && rms < SILENCE_RMS_FLOOR { - return Err(MagnotiaError::AudioCaptureFailed(format!( + return Err(Error::AudioCaptureFailed(format!( "device produced silence (rms={rms:.6} below floor {SILENCE_RMS_FLOOR:.6})" ))); } @@ -485,21 +500,26 @@ fn open_and_validate( // long stream of f32 zeros from a borked device — that is worse than // failing fast. if rms < DEAD_SILENCE_FLOOR { - return Err(MagnotiaError::AudioCaptureFailed(format!( + return Err(Error::AudioCaptureFailed(format!( "device produced dead silence (rms={rms:.6e} below absolute floor {DEAD_SILENCE_FLOOR:.6e})" ))); } - // Re-queue the collected chunks so downstream gets them. Count any - // drops here against the same `dropped_chunks` counter so the live - // session sees them and can warn the user. - for chunk in collected { - if requeue_tx.try_send(chunk).is_err() { - dropped_chunks.fetch_add(1, Ordering::Relaxed); - } - } + // Hand the validation pre-roll back to the consumer as a separate + // VecDeque rather than try_send-requeuing into the 32-slot channel. + // On small-buffer audio hosts (WASAPI exclusive at ~256 frames / + // low-latency ALSA) the 350ms window collects ~65 chunks; the old + // requeue path silently dropped roughly half of them, losing ~150ms + // from the head of every recording. The consumer-side drain + // bypasses the channel cap entirely. + let replay_buffer: VecDeque = collected.into_iter().collect(); - tracing::info!(target: "magnotia_audio", device = %name, "selected microphone"); + tracing::info!( + target: "lumotia_audio", + device = %name, + replay_chunks = replay_buffer.len(), + "selected microphone" + ); Ok(( MicrophoneCapture { stream: Some(stream), @@ -507,6 +527,7 @@ fn open_and_validate( dropped_chunks, error_rx: Some(err_rx), }, + replay_buffer, rx, )) } @@ -547,7 +568,7 @@ where move |err| { // Surface stream errors to the live session via err_tx so the // frontend can show a toast. - tracing::error!(target: "magnotia_audio", error = %err, "capture stream error"); + tracing::error!(target: "lumotia_audio", error = %err, "capture stream error"); if err_tx .try_send(CaptureRuntimeError { device_name: err_device_name.clone(), @@ -560,7 +581,7 @@ where // even if the frontend never received the typed event. let prior = dropped_errors.fetch_add(1, Ordering::Relaxed); tracing::warn!( - target: "magnotia_audio", + target: "lumotia_audio", device = %err_device_name, dropped_error = prior + 1, "capture error channel full; dropping runtime error" diff --git a/crates/audio/src/concurrency.rs b/crates/audio/src/concurrency.rs index e0e68fb..c74a7c3 100644 --- a/crates/audio/src/concurrency.rs +++ b/crates/audio/src/concurrency.rs @@ -1,7 +1,7 @@ use std::path::Path; -use magnotia_core::error::Result; -use magnotia_core::types::AudioSamples; +use lumotia_core::error::Result; +use lumotia_core::types::AudioSamples; use crate::decode::decode_audio_file; use crate::resample::resample_to_16khz; @@ -15,7 +15,5 @@ pub async fn decode_and_resample(path: &Path) -> Result { resample_to_16khz(&audio) }) .await - .map_err(|e| { - magnotia_core::error::MagnotiaError::AudioDecodeFailed(format!("Task join error: {e}")) - })? + .map_err(|e| lumotia_core::error::Error::AudioDecodeFailed(format!("Task join error: {e}")))? } diff --git a/crates/audio/src/decode.rs b/crates/audio/src/decode.rs index d4953c9..8427123 100644 --- a/crates/audio/src/decode.rs +++ b/crates/audio/src/decode.rs @@ -9,13 +9,13 @@ use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; -use magnotia_core::error::{MagnotiaError, Result}; -use magnotia_core::types::AudioSamples; +use lumotia_core::error::{Error, Result}; +use lumotia_core::types::AudioSamples; /// Decode an audio file to mono f32 PCM samples. /// Supports all formats symphonia handles: mp3, aac, flac, wav, ogg, etc. /// -/// Any read- or decode-side error is propagated as `MagnotiaError::AudioDecodeFailed`. +/// Any read- or decode-side error is propagated as `Error::AudioDecodeFailed`. /// A previous implementation `break`ed out of the packet loop on any read /// error and skipped per-packet decode errors, so a truncated or corrupt /// input silently returned `Ok` with whatever had decoded before the @@ -28,8 +28,8 @@ pub fn decode_audio_file_limited( path: &Path, max_duration_secs: Option, ) -> Result { - let file = File::open(path) - .map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Cannot open file: {e}")))?; + let file = + File::open(path).map_err(|e| Error::AudioDecodeFailed(format!("Cannot open file: {e}")))?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mut hint = Hint::new(); @@ -41,8 +41,8 @@ pub fn decode_audio_file_limited( } pub fn probe_audio_duration_secs(path: &Path) -> Result> { - let file = File::open(path) - .map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Cannot open file: {e}")))?; + let file = + File::open(path).map_err(|e| Error::AudioDecodeFailed(format!("Cannot open file: {e}")))?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { @@ -56,15 +56,15 @@ pub fn probe_audio_duration_secs(path: &Path) -> Result> { &FormatOptions::default(), &MetadataOptions::default(), ) - .map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Unsupported format: {e}")))?; + .map_err(|e| Error::AudioDecodeFailed(format!("Unsupported format: {e}")))?; let track = probed .format .default_track() - .ok_or_else(|| MagnotiaError::AudioDecodeFailed("No audio track found".into()))?; + .ok_or_else(|| Error::AudioDecodeFailed("No audio track found".into()))?; let sample_rate = track .codec_params .sample_rate - .ok_or_else(|| MagnotiaError::AudioDecodeFailed("Unknown sample rate".into()))?; + .ok_or_else(|| Error::AudioDecodeFailed("Unknown sample rate".into()))?; Ok(track .codec_params .n_frames @@ -86,22 +86,20 @@ fn decode_media_stream( &FormatOptions::default(), &MetadataOptions::default(), ) - .map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Unsupported format: {e}")))?; + .map_err(|e| Error::AudioDecodeFailed(format!("Unsupported format: {e}")))?; let mut format = probed.format; let track = format .default_track() - .ok_or_else(|| MagnotiaError::AudioDecodeFailed("No audio track found".into()))?; + .ok_or_else(|| Error::AudioDecodeFailed("No audio track found".into()))?; let sample_rate = track .codec_params .sample_rate - .ok_or_else(|| MagnotiaError::AudioDecodeFailed("Unknown sample rate".into()))?; + .ok_or_else(|| Error::AudioDecodeFailed("Unknown sample rate".into()))?; if sample_rate == 0 { - return Err(MagnotiaError::AudioDecodeFailed( - "Invalid sample rate: 0".into(), - )); + return Err(Error::AudioDecodeFailed("Invalid sample rate: 0".into())); } let track_id = track.id; @@ -109,7 +107,7 @@ fn decode_media_stream( let mut decoder = symphonia::default::get_codecs() .make(&track.codec_params, &DecoderOptions::default()) - .map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Codec error: {e}")))?; + .map_err(|e| Error::AudioDecodeFailed(format!("Codec error: {e}")))?; let mut samples: Vec = Vec::new(); @@ -123,14 +121,12 @@ fn decode_media_stream( break; } Err(SymphoniaError::ResetRequired) => { - return Err(MagnotiaError::AudioDecodeFailed( + return Err(Error::AudioDecodeFailed( "decoder reset required mid-stream — input contains a discontinuity".into(), )); } Err(e) => { - return Err(MagnotiaError::AudioDecodeFailed(format!( - "packet read failed: {e}" - ))); + return Err(Error::AudioDecodeFailed(format!("packet read failed: {e}"))); } }; @@ -140,7 +136,7 @@ fn decode_media_stream( let decoded = decoder .decode(&packet) - .map_err(|e| MagnotiaError::AudioDecodeFailed(format!("packet decode failed: {e}")))?; + .map_err(|e| Error::AudioDecodeFailed(format!("packet decode failed: {e}")))?; let spec = *decoded.spec(); let channels = spec.channels.count(); @@ -160,7 +156,7 @@ fn decode_media_stream( .map(|limit| samples.len() > limit) .unwrap_or(false) { - return Err(MagnotiaError::AudioDecodeFailed(format!( + return Err(Error::AudioDecodeFailed(format!( "Audio is longer than the {:.0} minute import limit", max_duration_secs.unwrap_or(0.0) / 60.0 ))); @@ -168,9 +164,7 @@ fn decode_media_stream( } if samples.is_empty() { - return Err(MagnotiaError::AudioDecodeFailed( - "No audio data decoded".into(), - )); + return Err(Error::AudioDecodeFailed("No audio data decoded".into())); } Ok(AudioSamples::new(samples, sample_rate, 1)) @@ -191,7 +185,7 @@ mod tests { } fn valid_wav_bytes(sample_count: usize) -> Vec { - let path = temp_path("magnotia_decode_tmp_for_bytes.wav"); + let path = temp_path("lumotia_decode_tmp_for_bytes.wav"); let samples: Vec = (0..sample_count).map(|i| (i as f32) / 1000.0).collect(); let audio = AudioSamples::mono_16khz(samples); write_wav(&path, &audio).unwrap(); @@ -238,7 +232,7 @@ mod tests { #[test] fn decodes_valid_wav_successfully() { - let path = temp_path("magnotia_decode_valid.wav"); + let path = temp_path("lumotia_decode_valid.wav"); let samples: Vec = (0..4_000).map(|i| (i as f32) / 1000.0).collect(); write_wav(&path, &AudioSamples::mono_16khz(samples)).unwrap(); @@ -251,7 +245,7 @@ mod tests { #[test] fn missing_file_surfaces_error() { - let path = temp_path("magnotia_decode_missing.wav"); + let path = temp_path("lumotia_decode_missing.wav"); let result = decode_audio_file(&path); assert!(result.is_err(), "missing file must error, got: {result:?}"); } diff --git a/crates/audio/src/resample.rs b/crates/audio/src/resample.rs index 93301f8..33b38be 100644 --- a/crates/audio/src/resample.rs +++ b/crates/audio/src/resample.rs @@ -2,9 +2,9 @@ use rubato::{ Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction, }; -use magnotia_core::constants::WHISPER_SAMPLE_RATE; -use magnotia_core::error::{MagnotiaError, Result}; -use magnotia_core::types::AudioSamples; +use lumotia_core::constants::WHISPER_SAMPLE_RATE; +use lumotia_core::error::{Error, Result}; +use lumotia_core::types::AudioSamples; /// Resample audio to 16kHz mono using sinc interpolation (rubato). /// Returns a new AudioSamples at the target sample rate. @@ -17,7 +17,7 @@ pub fn resample_to_16khz(audio: &AudioSamples) -> Result { } if from_rate == 0 { - return Err(MagnotiaError::AudioDecodeFailed( + return Err(Error::AudioDecodeFailed( "Cannot resample: source rate is 0".into(), )); } @@ -36,7 +36,7 @@ pub fn resample_to_16khz(audio: &AudioSamples) -> Result { let mut resampler = SincFixedIn::::new( ratio, 1.1, params, chunk_size, 1, // mono ) - .map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Resampler init failed: {e}")))?; + .map_err(|e| Error::AudioDecodeFailed(format!("Resampler init failed: {e}")))?; let samples = audio.samples(); let mut output_samples: Vec = Vec::new(); @@ -53,7 +53,7 @@ pub fn resample_to_16khz(audio: &AudioSamples) -> Result { let input = vec![chunk]; let result = resampler .process(&input, None) - .map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Resample failed: {e}")))?; + .map_err(|e| Error::AudioDecodeFailed(format!("Resample failed: {e}")))?; if !result.is_empty() && !result[0].is_empty() { output_samples.extend_from_slice(&result[0]); diff --git a/crates/audio/src/streaming_resample.rs b/crates/audio/src/streaming_resample.rs index 17984cc..6cb1ea9 100644 --- a/crates/audio/src/streaming_resample.rs +++ b/crates/audio/src/streaming_resample.rs @@ -27,8 +27,8 @@ use rubato::{ Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction, }; -use magnotia_core::constants::WHISPER_SAMPLE_RATE; -use magnotia_core::error::{MagnotiaError, Result}; +use lumotia_core::constants::WHISPER_SAMPLE_RATE; +use lumotia_core::error::{Error, Result}; /// Number of input samples the rubato resampler consumes per `process()` /// call. Matches the chunk size used in `resample::resample_to_16khz`. @@ -51,7 +51,7 @@ impl StreamingResampler { /// rubato rejects the requested ratio. pub fn new(from_rate: u32) -> Result { if from_rate == 0 { - return Err(MagnotiaError::AudioDecodeFailed( + return Err(Error::AudioDecodeFailed( "StreamingResampler: input sample rate is 0".into(), )); } @@ -77,9 +77,7 @@ impl StreamingResampler { INPUT_CHUNK, 1, // mono ) - .map_err(|e| { - MagnotiaError::AudioDecodeFailed(format!("StreamingResampler init failed: {e}")) - })?; + .map_err(|e| Error::AudioDecodeFailed(format!("StreamingResampler init failed: {e}")))?; Ok(Self::Sinc { resampler, @@ -110,9 +108,7 @@ impl StreamingResampler { let chunk: Vec = residual.drain(..INPUT_CHUNK).collect(); let input = vec![chunk]; let result = resampler.process(&input, None).map_err(|e| { - MagnotiaError::AudioDecodeFailed(format!( - "StreamingResampler process failed: {e}" - )) + Error::AudioDecodeFailed(format!("StreamingResampler process failed: {e}")) })?; if let Some(channel) = result.into_iter().next() { out.extend_from_slice(&channel); @@ -144,9 +140,7 @@ impl StreamingResampler { let input = vec![chunk]; let result = resampler.process(&input, None).map_err(|e| { - MagnotiaError::AudioDecodeFailed(format!( - "StreamingResampler flush failed: {e}" - )) + Error::AudioDecodeFailed(format!("StreamingResampler flush failed: {e}")) })?; let Some(mut out) = result.into_iter().next() else { diff --git a/crates/audio/src/vad.rs b/crates/audio/src/vad.rs index 700ef15..3033b3e 100644 --- a/crates/audio/src/vad.rs +++ b/crates/audio/src/vad.rs @@ -7,7 +7,7 @@ // For now, all audio is treated as speech. This matches v0.2 behaviour // (no VAD) and doesn't affect core functionality. -use magnotia_core::constants::VAD_SPEECH_THRESHOLD; +use lumotia_core::constants::VAD_SPEECH_THRESHOLD; /// Stub speech detector. Treats all audio as speech. #[derive(Default)] diff --git a/crates/audio/src/wav.rs b/crates/audio/src/wav.rs index fd53851..8cab9d7 100644 --- a/crates/audio/src/wav.rs +++ b/crates/audio/src/wav.rs @@ -1,8 +1,8 @@ use std::io::BufWriter; use std::path::Path; -use magnotia_core::error::{MagnotiaError, Result}; -use magnotia_core::types::AudioSamples; +use lumotia_core::error::{Error, Result}; +use lumotia_core::types::AudioSamples; /// Append-friendly WAV writer for long-running captures. /// @@ -40,11 +40,10 @@ impl WavWriter { bits_per_sample: 16, sample_format: hound::SampleFormat::Int, }; - let file = std::fs::File::create(path).map_err(MagnotiaError::from)?; + let file = std::fs::File::create(path).map_err(Error::from)?; let buffered = BufWriter::new(file); - let inner = hound::WavWriter::new(buffered, spec).map_err(|e| { - MagnotiaError::from(std::io::Error::other(format!("WAV create failed: {e}"))) - })?; + let inner = hound::WavWriter::new(buffered, spec) + .map_err(|e| Error::from(std::io::Error::other(format!("WAV create failed: {e}"))))?; Ok(Self { inner, samples_since_flush: 0, @@ -61,7 +60,7 @@ impl WavWriter { let clamped = sample.clamp(-1.0, 1.0); let int_sample = (clamped * i16::MAX as f32) as i16; self.inner.write_sample(int_sample).map_err(|e| { - MagnotiaError::from(std::io::Error::other(format!("WAV write failed: {e}"))) + Error::from(std::io::Error::other(format!("WAV write failed: {e}"))) })?; } self.samples_since_flush += samples.len(); @@ -77,9 +76,9 @@ impl WavWriter { /// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural /// boundaries (end-of-utterance, UI events) for tighter recovery. pub fn flush(&mut self) -> Result<()> { - self.inner.flush().map_err(|e| { - MagnotiaError::from(std::io::Error::other(format!("WAV flush failed: {e}"))) - })?; + self.inner + .flush() + .map_err(|e| Error::from(std::io::Error::other(format!("WAV flush failed: {e}"))))?; self.samples_since_flush = 0; Ok(()) } @@ -89,9 +88,9 @@ impl WavWriter { /// writer leaves a playable file up to the last flush; callers /// that care about the unflushed tail should always finalise. pub fn finalize(self) -> Result<()> { - self.inner.finalize().map_err(|e| { - MagnotiaError::from(std::io::Error::other(format!("WAV finalize failed: {e}"))) - })?; + self.inner + .finalize() + .map_err(|e| Error::from(std::io::Error::other(format!("WAV finalize failed: {e}"))))?; Ok(()) } } @@ -105,35 +104,34 @@ pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> { sample_format: hound::SampleFormat::Int, }; - let mut writer = hound::WavWriter::create(path, spec).map_err(|e| { - MagnotiaError::from(std::io::Error::other(format!("WAV create failed: {e}"))) - })?; + let mut writer = hound::WavWriter::create(path, spec) + .map_err(|e| Error::from(std::io::Error::other(format!("WAV create failed: {e}"))))?; for &sample in audio.samples() { let clamped = sample.clamp(-1.0, 1.0); let int_sample = (clamped * i16::MAX as f32) as i16; - writer.write_sample(int_sample).map_err(|e| { - MagnotiaError::from(std::io::Error::other(format!("WAV write failed: {e}"))) - })?; + writer + .write_sample(int_sample) + .map_err(|e| Error::from(std::io::Error::other(format!("WAV write failed: {e}"))))?; } - writer.finalize().map_err(|e| { - MagnotiaError::from(std::io::Error::other(format!("WAV finalize failed: {e}"))) - })?; + writer + .finalize() + .map_err(|e| Error::from(std::io::Error::other(format!("WAV finalize failed: {e}"))))?; Ok(()) } /// Read a WAV file to f32 PCM `AudioSamples`. /// -/// Any per-sample decode error is surfaced as `MagnotiaError::AudioDecodeFailed` +/// Any per-sample decode error is surfaced as `Error::AudioDecodeFailed` /// rather than silently dropped. A previous implementation used /// `filter_map(|s| s.ok())`, so a truncated or corrupt payload returned /// a short, silently-partial `AudioSamples` — callers got `Ok` while /// losing audio (flagged by the 2026-04-22 review). pub fn read_wav(path: &Path) -> Result { let reader = hound::WavReader::open(path) - .map_err(|e| MagnotiaError::AudioDecodeFailed(format!("WAV open failed: {e}")))?; + .map_err(|e| Error::AudioDecodeFailed(format!("WAV open failed: {e}")))?; let spec = reader.spec(); let sample_rate = spec.sample_rate; @@ -146,17 +144,14 @@ pub fn read_wav(path: &Path) -> Result { .map(|sample| { sample .map(|s| s as f32 / (1 << (bits_per_sample - 1)) as f32) - .map_err(|e| { - MagnotiaError::AudioDecodeFailed(format!("WAV sample decode failed: {e}")) - }) + .map_err(|e| Error::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))) }) .collect::>>()?, hound::SampleFormat::Float => reader .into_samples::() .map(|sample| { - sample.map_err(|e| { - MagnotiaError::AudioDecodeFailed(format!("WAV sample decode failed: {e}")) - }) + sample + .map_err(|e| Error::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))) }) .collect::>>()?, }; @@ -172,7 +167,7 @@ mod tests { fn wav_writer_survives_crash() { // Property under test: a `WavWriter` that has been flushed but // never finalised leaves a valid, readable WAV on disk. This - // is the crash-safety guarantee — if the magnotia process aborts + // is the crash-safety guarantee — if the lumotia process aborts // mid-session, the on-disk file up to the last flush is // recoverable. // @@ -182,7 +177,7 @@ mod tests { // mirrors what happens when the OS reaps the process without // giving Rust a chance to run destructors. let temp_dir = std::env::temp_dir(); - let path = temp_dir.join("magnotia_test_wav_writer_survives_crash.wav"); + let path = temp_dir.join("lumotia_test_wav_writer_survives_crash.wav"); let _ = std::fs::remove_file(&path); let mut writer = WavWriter::create(&path, 16_000, 1).unwrap(); @@ -219,7 +214,7 @@ mod tests { #[test] fn wav_writer_append_then_finalize_roundtrips() { let temp_dir = std::env::temp_dir(); - let path = temp_dir.join("magnotia_test_wav_writer_finalize.wav"); + let path = temp_dir.join("lumotia_test_wav_writer_finalize.wav"); let _ = std::fs::remove_file(&path); let mut writer = WavWriter::create(&path, 16_000, 1).unwrap(); @@ -241,7 +236,7 @@ mod tests { // truncated WAV returned Ok with a short samples vec. The // new code must propagate the error. let temp_dir = std::env::temp_dir(); - let path = temp_dir.join("magnotia_test_truncated_wav.wav"); + let path = temp_dir.join("lumotia_test_truncated_wav.wav"); let _ = std::fs::remove_file(&path); // Write 100 samples (200 bytes at 16-bit). @@ -267,7 +262,7 @@ mod tests { #[test] fn wav_roundtrip() { let temp_dir = std::env::temp_dir(); - let path = temp_dir.join("magnotia_test_roundtrip.wav"); + let path = temp_dir.join("lumotia_test_roundtrip.wav"); let original = AudioSamples::mono_16khz(vec![0.0, 0.5, -0.5, 0.25, -0.25]); write_wav(&path, &original).unwrap(); diff --git a/crates/cloud-providers/Cargo.toml b/crates/cloud-providers/Cargo.toml index b8ecc4f..1f38877 100644 --- a/crates/cloud-providers/Cargo.toml +++ b/crates/cloud-providers/Cargo.toml @@ -1,11 +1,13 @@ [package] -name = "magnotia-cloud-providers" -version = "0.1.0" -edition = "2021" -description = "Provider trait and BYOK cloud STT scaffolding for Magnotia (Wyrdnote pending rebrand)" +name = "lumotia-cloud-providers" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +description = "Provider trait and BYOK cloud STT scaffolding for Lumotia" [dependencies] -magnotia-core = { path = "../core" } +lumotia-core = { path = "../core" } # Async-native trait. async_trait converts async fn into Pin> at the trait surface so TranscriptionProvider stays diff --git a/crates/cloud-providers/src/keystore.rs b/crates/cloud-providers/src/keystore.rs index e82e12b..cf9bd53 100644 --- a/crates/cloud-providers/src/keystore.rs +++ b/crates/cloud-providers/src/keystore.rs @@ -1,13 +1,13 @@ use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; -/// Store an API key in Magnotia's process-local keystore. +/// Store an API key in Lumotia's process-local keystore. /// /// Keys are held in memory for the lifetime of the process and are lost on /// exit. This avoids the undefined behaviour of mutating process environment /// variables from arbitrary threads while keeping the public API safe. /// -/// `retrieve_api_key` still falls back to `MAGNOTIA_API_KEY_` environment +/// `retrieve_api_key` still falls back to `LUMOTIA_API_KEY_` environment /// variables so externally injected secrets continue to work. /// /// TODO: Replace with the `keyring` crate (or platform-native credential @@ -19,10 +19,10 @@ pub fn store_api_key(provider: &str, key: &str) { .insert(provider_env_key(provider), key.to_string()); } -/// Retrieve an API key from Magnotia's process-local keystore. +/// Retrieve an API key from Lumotia's process-local keystore. /// /// Returns a previously stored in-memory key when present, otherwise falls -/// back to the read-only `MAGNOTIA_API_KEY_` environment variable so +/// back to the read-only `LUMOTIA_API_KEY_` environment variable so /// operator-supplied secrets still work. pub fn retrieve_api_key(provider: &str) -> Option { let env_key = provider_env_key(provider); @@ -40,7 +40,7 @@ fn api_key_store() -> &'static Mutex> { } fn provider_env_key(provider: &str) -> String { - format!("MAGNOTIA_API_KEY_{}", provider.to_uppercase()) + format!("LUMOTIA_API_KEY_{}", provider.to_uppercase()) } #[cfg(test)] diff --git a/crates/cloud-providers/src/provider.rs b/crates/cloud-providers/src/provider.rs index 5c85b3e..875621b 100644 --- a/crates/cloud-providers/src/provider.rs +++ b/crates/cloud-providers/src/provider.rs @@ -1,12 +1,12 @@ //! `TranscriptionProvider` is the async-native trait every transcription //! backend implements, regardless of whether the work happens locally //! (Whisper, Parakeet, Moonshine via the `LocalProviderAdapter` in -//! `magnotia-transcription`) or remotely (OpenAI Whisper, Groq, +//! `lumotia-transcription`) or remotely (OpenAI Whisper, Groq, //! Deepgram, etc.). //! -//! Living in `magnotia-cloud-providers` is deliberate: the AGPL OEM +//! Living in `lumotia-cloud-providers` is deliberate: the AGPL OEM //! exception (≥£2k/yr) requires a clean trait surface that an OEM -//! licensee can implement without depending on Wyrdnote's transcription +//! licensee can implement without depending on Lumotia's transcription //! internals. The trait crate stays small; provider implementations //! sit alongside. //! @@ -16,8 +16,8 @@ use std::fmt; use async_trait::async_trait; -use magnotia_core::error::Result; -use magnotia_core::types::{AudioSamples, ModelId, Transcript, TranscriptionOptions}; +use lumotia_core::error::Result; +use lumotia_core::types::{AudioSamples, ModelId, Transcript, TranscriptionOptions}; use serde::{Deserialize, Serialize}; /// Stable, lower-kebab-case identifier for a provider. Used in user @@ -70,7 +70,7 @@ pub enum CostClass { } /// Capabilities a provider advertises to the orchestrator and the UI. -/// Superset of `magnotia_transcription::TranscriberCapabilities` for +/// Superset of `lumotia_transcription::TranscriberCapabilities` for /// local providers, with extra fields cloud providers populate. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct ProviderCapabilities { @@ -175,10 +175,10 @@ mod tests { engine_id: ProviderId::new("local-whisper"), model_id: None, language: Some("en".to_string()), - initial_prompt: Some("Wyrdnote".to_string()), + initial_prompt: Some("Lumotia".to_string()), }; let opts = profile.to_options(); assert_eq!(opts.language, Some("en".to_string())); - assert_eq!(opts.initial_prompt, Some("Wyrdnote".to_string())); + assert_eq!(opts.initial_prompt, Some("Lumotia".to_string())); } } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d432aa1..9a65d9a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,8 +1,10 @@ [package] -name = "magnotia-core" -version = "0.1.0" -edition = "2021" -description = "Core types, constants, traits, hardware detection, and model registry for Magnotia" +name = "lumotia-core" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +description = "Core types, constants, traits, hardware detection, and model registry for Lumotia" [dependencies] serde = { version = "1", features = ["derive"] } diff --git a/crates/core/examples/tuning_log_demo.rs b/crates/core/examples/tuning_log_demo.rs index ac35be7..4d8218e 100644 --- a/crates/core/examples/tuning_log_demo.rs +++ b/crates/core/examples/tuning_log_demo.rs @@ -3,17 +3,17 @@ //! gpu_offloaded) tuples. //! //! Run with: -//! cargo run -p magnotia-core --example tuning_log_demo +//! cargo run -p lumotia-core --example tuning_log_demo //! //! Output is to stderr (tracing's default). Each unique tuple emits //! exactly one INFO line; subsequent calls with the same tuple are //! silenced by the per-process log-once cache. -use magnotia_core::tuning::{inference_thread_count, Workload}; +use lumotia_core::tuning::{inference_thread_count, Workload}; fn main() { tracing_subscriber::fmt() - .with_env_filter("magnotia_core=info") + .with_env_filter("lumotia_core=info") .with_target(true) .with_writer(std::io::stderr) .init(); @@ -28,8 +28,8 @@ fn main() { ("No override (real sysfs probe)", None), ] { match override_value { - Some(v) => std::env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", v), - None => std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE"), + Some(v) => std::env::set_var("LUMOTIA_POWER_STATE_OVERRIDE", v), + None => std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE"), } // Cache invalidation so the live probe re-runs each section. // Override paths bypass the cache anyway; this is for the diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 0a318ab..9fa7537 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -4,12 +4,12 @@ use serde::Serialize; use crate::types::ModelId; -/// Structured error type for Magnotia. +/// Structured error type for Lumotia. /// /// Implements `Serialize` so errors can be sent to the frontend as /// structured JSON rather than opaque strings. #[derive(Debug, thiserror::Error, Serialize)] -pub enum MagnotiaError { +pub enum Error { #[error("model not found: {0}")] ModelNotFound(ModelId), @@ -22,6 +22,14 @@ pub enum MagnotiaError { #[error("transcription failed: {0}")] TranscriptionFailed(String), + /// Inference exceeded the bounded wait imposed by the caller (live + /// session `drain_inference`). The spawned worker has had its abort + /// flag set so whisper-rs will return early on its next + /// abort-callback poll; the lock-up itself is *not* recovered by + /// this error — but the live-session lifecycle can now progress. + #[error("inference timed out after {timeout_ms}ms")] + InferenceTimeout { timeout_ms: u64 }, + #[error("audio decode failed: {0}")] AudioDecodeFailed(String), @@ -34,7 +42,7 @@ pub enum MagnotiaError { #[error("file not found: '{}'", .0.display())] FileNotFound(PathBuf), - /// Structured storage failure flowed up from `magnotia_storage::Error` via + /// Structured storage failure flowed up from `lumotia_storage::Error` via /// its `From` impl. Display reads through to `detail` so the operation + /// source context produced by the storage crate isn't double-prefixed. #[error("{detail}")] @@ -55,10 +63,10 @@ pub enum MagnotiaError { }, } -/// Coarse discriminator for `MagnotiaError::Storage`. The storage crate maps +/// Coarse discriminator for `Error::Storage`. The storage crate maps /// its full typed error onto one of these kinds at the boundary. Backend code /// that wants finer-grained branching pattern-matches on -/// `magnotia_storage::Error` directly. +/// `lumotia_storage::Error` directly. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "snake_case")] pub enum StorageKind { @@ -70,7 +78,7 @@ pub enum StorageKind { Filesystem, } -impl From for MagnotiaError { +impl From for Error { fn from(err: std::io::Error) -> Self { Self::Io { kind: format!("{:?}", err.kind()), @@ -80,4 +88,4 @@ impl From for MagnotiaError { } } -pub type Result = std::result::Result; +pub type Result = std::result::Result; diff --git a/crates/core/src/hardware.rs b/crates/core/src/hardware.rs index 1fa2766..f2f79ef 100644 --- a/crates/core/src/hardware.rs +++ b/crates/core/src/hardware.rs @@ -19,7 +19,7 @@ pub struct CpuInfo { } /// Runtime-detected CPU feature flags relevant to the speech-to-text -/// and LLM backends Magnotia ships. All whisper.cpp / llama.cpp / ggml +/// and LLM backends Lumotia ships. All whisper.cpp / llama.cpp / ggml /// kernels degrade roughly two tiers without AVX2, which is why we /// surface it separately: when AVX2 is absent, the UI should warn the /// user that performance will be a fraction of what they would see diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 4889b32..32bd2e8 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,7 +9,7 @@ pub mod recommendation; pub mod tuning; pub mod types; -pub use error::{MagnotiaError, Result}; +pub use error::{Error, Result}; pub use types::{ AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment, Transcript, TranscriptionOptions, diff --git a/crates/core/src/paths.rs b/crates/core/src/paths.rs index 2f0ab1f..9229361 100644 --- a/crates/core/src/paths.rs +++ b/crates/core/src/paths.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::types::ModelId; @@ -19,7 +19,7 @@ impl AppPaths { } pub fn database_path(&self) -> PathBuf { - self.app_data_dir.join("magnotia.db") + self.app_data_dir.join("lumotia.db") } pub fn recordings_dir(&self) -> PathBuf { @@ -49,10 +49,6 @@ impl AppPaths { pub fn llm_models_dir(&self) -> PathBuf { self.models_dir().join("llm") } - - pub fn migration_sentinel(&self, name: &str) -> PathBuf { - self.app_data_dir.join(format!(".{name}.sentinel")) - } } pub fn app_paths() -> AppPaths { @@ -63,11 +59,156 @@ pub fn app_data_dir() -> PathBuf { app_paths().app_data_dir() } +/// Surfaced when two or more lumotia data-dir candidates exist on disk +/// simultaneously (e.g. both `~/.lumotia` and `~/.local/share/lumotia`). +/// Picking one silently risks pointing at the wrong copy of the user's +/// transcripts. The caller (typically the Tauri setup hook) should refuse +/// to start and surface the paths to the user for manual consolidation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TargetAmbiguityError { + pub candidates: Vec, +} + +impl std::fmt::Display for TargetAmbiguityError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "ambiguous lumotia data directory — multiple candidate paths exist: {}. \ + Please consolidate manually (move data into one path and delete the other) \ + then restart.", + self.candidates + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") + ) + } +} + +impl std::error::Error for TargetAmbiguityError {} + fn resolve_app_data_dir() -> PathBuf { + match resolve_app_data_dir_strict() { + Ok(p) => p, + Err(e) => { + // Refuse to start rather than silently picking one of several + // candidate target paths. This is intentionally a panic — the + // process must not be allowed to begin writing into the wrong + // half of a split data directory. The setup hook also calls + // `check_target_ambiguity` explicitly to surface this error + // before tracing/log subsystems are spun up. + panic!("{e}"); + } + } +} + +/// Fallible variant of [`resolve_app_data_dir`]: returns the conventional +/// target path for the current platform, or a [`TargetAmbiguityError`] if +/// more than one candidate target path currently exists on disk. +/// +/// Public so that the application setup hook can perform the check +/// explicitly (and report the ambiguity through tracing) rather than +/// relying on the panic that backs the infallible `resolve_app_data_dir`. +pub fn resolve_app_data_dir_strict() -> Result { + let candidates = target_data_dir_candidates(); + let existing: Vec = candidates.iter().filter(|p| p.exists()).cloned().collect(); + if existing.len() > 1 { + return Err(TargetAmbiguityError { + candidates: existing, + }); + } + // If exactly one candidate exists, prefer it (it's where the user's + // data lives). If none exist, fall through to the platform-canonical + // path so a fresh install creates the right convention. + if existing.len() == 1 { + return Ok(existing.into_iter().next().unwrap()); + } + Ok(canonical_target_data_dir()) +} + +/// Public counterpart to [`resolve_app_data_dir_strict`] returning `Ok(())` +/// when the data dir is unambiguous and the [`TargetAmbiguityError`] +/// otherwise. Useful when the caller just wants to fail-fast at boot +/// without yet caring about the path itself. +pub fn check_target_ambiguity() -> Result<(), TargetAmbiguityError> { + resolve_app_data_dir_strict().map(|_| ()) +} + +/// All conventional lumotia data-dir target paths for the current +/// platform. Lumotia chooses one canonical path at install time, but a +/// previous magnotia install or a hand-edited XDG_DATA_HOME can leave +/// data in any of these — the migration driver probes them all and the +/// resolver refuses to start if more than one survives. +fn target_data_dir_candidates() -> Vec { + let mut out = Vec::new(); + + #[cfg(target_os = "windows")] + { + if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { + if !local_app_data.is_empty() { + out.push(PathBuf::from(local_app_data).join("lumotia")); + } + } + } + + #[cfg(target_os = "macos")] + { + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + out.push( + PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("Lumotia"), + ); + } + } + } + + #[cfg(target_os = "linux")] + { + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + out.push(PathBuf::from(&home).join(".lumotia")); + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + out.push(PathBuf::from(xdg).join("lumotia")); + } + } + out.push( + PathBuf::from(home) + .join(".local") + .join("share") + .join("lumotia"), + ); + } + } + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + out.push(PathBuf::from(home).join(".lumotia")); + } + } + } + + // De-duplicate while preserving order: on Linux XDG_DATA_HOME may be + // set to `~/.local/share` explicitly, in which case the explicit XDG + // candidate and the XDG default collapse to one path. + let mut seen = std::collections::HashSet::new(); + out.retain(|p| seen.insert(p.clone())); + out +} + +/// The single canonical target path for the current platform — what a +/// fresh install would create. Used when no existing candidate is found. +fn canonical_target_data_dir() -> PathBuf { #[cfg(target_os = "windows")] { let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()); - return PathBuf::from(local_app_data).join("magnotia"); + return PathBuf::from(local_app_data).join("lumotia"); } #[cfg(target_os = "macos")] @@ -76,56 +217,944 @@ fn resolve_app_data_dir() -> PathBuf { return PathBuf::from(home) .join("Library") .join("Application Support") - .join("Magnotia"); + .join("Lumotia"); } #[cfg(target_os = "linux")] { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - let legacy = PathBuf::from(&home).join(".magnotia"); - if legacy.exists() { - return legacy; - } if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if !xdg.is_empty() { - return PathBuf::from(xdg).join("magnotia"); + return PathBuf::from(xdg).join("lumotia"); } } PathBuf::from(home) .join(".local") .join("share") - .join("magnotia") + .join("lumotia") } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - PathBuf::from(home).join(".magnotia") + PathBuf::from(home).join(".lumotia") + } +} + +/// Outcome of attempting to migrate an existing magnotia-era data directory +/// to its lumotia equivalent on first launch after the rebrand. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MigrationStatus { + /// Renamed legacy dir to the new path. Includes optional db-file rename + /// if `magnotia.db` was found inside. + Migrated { + from: PathBuf, + to: PathBuf, + renamed_db: bool, + }, + /// New path already exists. Did not touch legacy (if any) to avoid + /// destroying user data on the new path. + TargetAlreadyExists { target: PathBuf }, + /// No legacy data dir on disk. Nothing to do. + NoLegacyFound, +} + +/// Probe ALL legacy magnotia data dir paths on the current platform. +/// Returns one (legacy, target) pair per legacy candidate that exists on +/// disk. The target is convention-preserving so the migration lands the +/// same kind of dir it found (dot-home stays dot-home, XDG stays XDG, +/// macOS Application Support stays the same). +/// +/// Previously this returned `Option<(legacy, target)>` and short-circuited +/// on the first match. On Linux that allowed a user with both +/// `~/.magnotia` AND `~/.local/share/magnotia` to migrate only one, +/// leaving the other orphaned forever (subsequent boots prefer the new +/// `~/.lumotia` so the XDG legacy is invisible). Now every legacy variant +/// is probed and migrated independently. +fn legacy_and_target_paths() -> Vec<(PathBuf, PathBuf)> { + let mut out = Vec::new(); + + #[cfg(target_os = "windows")] + { + if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { + if !local_app_data.is_empty() { + let legacy = PathBuf::from(&local_app_data).join("magnotia"); + let target = PathBuf::from(local_app_data).join("lumotia"); + if legacy.exists() { + out.push((legacy, target)); + } + } + } + } + + #[cfg(target_os = "macos")] + { + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + let app_support = PathBuf::from(home) + .join("Library") + .join("Application Support"); + let legacy = app_support.join("Magnotia"); + let target = app_support.join("Lumotia"); + if legacy.exists() { + out.push((legacy, target)); + } + } + } + } + + #[cfg(target_os = "linux")] + { + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + let dot_legacy = PathBuf::from(&home).join(".magnotia"); + if dot_legacy.exists() { + out.push((dot_legacy, PathBuf::from(&home).join(".lumotia"))); + } + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + let xdg_legacy = PathBuf::from(&xdg).join("magnotia"); + if xdg_legacy.exists() { + out.push((xdg_legacy, PathBuf::from(&xdg).join("lumotia"))); + } + } + } + let xdg_default_legacy = PathBuf::from(&home) + .join(".local") + .join("share") + .join("magnotia"); + if xdg_default_legacy.exists() { + let xdg_default_target = PathBuf::from(&home) + .join(".local") + .join("share") + .join("lumotia"); + out.push((xdg_default_legacy, xdg_default_target)); + } + } + } + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + let legacy = PathBuf::from(&home).join(".magnotia"); + let target = PathBuf::from(home).join(".lumotia"); + if legacy.exists() { + out.push((legacy, target)); + } + } + } + } + + // De-duplicate: e.g. XDG_DATA_HOME set explicitly to `~/.local/share` + // would otherwise produce the same pair twice on Linux. + let mut seen = std::collections::HashSet::new(); + out.retain(|pair| seen.insert(pair.clone())); + out +} + +/// Migrate every legacy magnotia data directory to its +/// convention-preserving lumotia equivalent on first launch. Idempotent: +/// safe to call on every boot. +/// +/// Returns one [`MigrationStatus`] per legacy candidate probed, in +/// platform-deterministic order. An empty Vec means there are no legacy +/// directories on disk (clean install). Callers should log per-candidate +/// outcomes and treat any `Err` as a hard startup failure: silently +/// continuing past a migration error orphans user data behind a fresh +/// empty lumotia dir. +/// +/// Per-candidate rules (same as before, applied independently to each +/// legacy path that exists): +/// * If the matching target already exists, do nothing for that +/// candidate and emit `TargetAlreadyExists`. We do not destroy +/// lumotia data, even if a stale legacy dir is also present. +/// * If only the legacy path exists, rename it to the matching lumotia +/// target (same convention) and rename `magnotia.db` -> `lumotia.db` +/// inside it if found. +pub fn migrate_legacy_data_dir() -> Result, std::io::Error> { + migrate_legacy_data_dir_with_pairs(legacy_and_target_paths()) +} + +/// Driver that takes the list of (legacy, target) pairs explicitly so +/// callers can substitute synthetic paths. Production path goes through +/// [`migrate_legacy_data_dir`], which resolves the pairs from +/// platform-specific HOME / LOCALAPPDATA / XDG env vars. Integration +/// tests in sibling crates call this directly with tempdir pairs. +/// +/// An empty input is shorthand for "no legacy on disk" and yields a +/// single [`MigrationStatus::NoLegacyFound`] entry so callers can still +/// rely on a non-empty result to drive their logging. +pub fn migrate_legacy_data_dir_with_pairs( + pairs: Vec<(PathBuf, PathBuf)>, +) -> Result, std::io::Error> { + if pairs.is_empty() { + return Ok(vec![MigrationStatus::NoLegacyFound]); + } + let mut out = Vec::with_capacity(pairs.len()); + for (from, to) in pairs { + out.push(migrate_one(from, to)?); + } + Ok(out) +} + +/// Run the single-candidate migration. Extracted so the driver can loop +/// over every legacy path discovered on disk and surface per-candidate +/// outcomes individually. +fn migrate_one(from: PathBuf, to: PathBuf) -> Result { + if to.exists() { + return Ok(MigrationStatus::TargetAlreadyExists { target: to }); + } + + if let Some(parent) = to.parent() { + std::fs::create_dir_all(parent)?; + } + rename_or_copy_tree(&from, &to)?; + + let renamed_db = rename_db_file_if_present(&to)?; + + Ok(MigrationStatus::Migrated { + from, + to, + renamed_db, + }) +} + +/// Move a directory tree, falling back to copy + remove if the +/// destination is on a different filesystem (EXDEV / CrossesDevices). +/// +/// Real-world cases this defends against: +/// * `~/.magnotia` on the user's home partition, `~/.local/share/lumotia` +/// on a bind-mounted partition. +/// * Encrypted-home (`/home/.ecryptfs/`) vs decrypted view. +/// * `$XDG_DATA_HOME` set to a non-home device. +/// +/// On a copy-then-remove fallback we tolerate partial cleanup failure +/// (the source dir not fully deleted) by surfacing the error from the +/// remove step only if the copy succeeded — losing data via a half-rolled +/// migration is worse than leaving a stale legacy dir behind. +fn rename_or_copy_tree(from: &Path, to: &Path) -> Result<(), std::io::Error> { + match std::fs::rename(from, to) { + Ok(()) => Ok(()), + Err(e) if is_cross_device(&e) => { + copy_dir_recursive(from, to)?; + // Only attempt removal after the copy fully succeeded. + // remove_dir_all is best-effort: if it leaves files behind + // (permission edge cases), the user can clean up the stale + // legacy dir manually. + std::fs::remove_dir_all(from)?; + Ok(()) + } + Err(e) => Err(e), + } +} + +fn is_cross_device(err: &std::io::Error) -> bool { + // ErrorKind::CrossesDevices is stable from Rust 1.85. Fall back to + // the raw OS error code (EXDEV = 18 on Linux, 17 on macOS, 17 on + // BSDs) for older toolchains. + if err.kind() == std::io::ErrorKind::CrossesDevices { + return true; + } + #[cfg(unix)] + { + return matches!(err.raw_os_error(), Some(18)); + } + #[allow(unreachable_code)] + false +} + +/// Symlink-aware recursive directory copy used by the legacy data-dir +/// migration and by the Tauri-side `app_data_dir` bundle-identifier +/// migration in `src-tauri/src/tauri_app_data_migration.rs`. Exposed as +/// `pub` so callers in sibling crates can reuse the same hardened +/// implementation (see commit history for the symlink-loop defence). +pub fn copy_dir_recursive(from: &Path, to: &Path) -> Result<(), std::io::Error> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let entry_path = entry.path(); + let target_path = to.join(entry.file_name()); + // CRITICAL: use file_type() rather than metadata(). metadata() + // follows symlinks, so a directory symlink reports is_dir==true + // and would recurse unconditionally — a self-referential or + // ancestor-targeting directory symlink loops until the disk + // fills. file_type() is symlink-aware on both Unix and Windows. + let file_type = entry.file_type()?; + if file_type.is_symlink() { + // Recreate symlink rather than dereferencing — the + // transcription app stores recording paths verbatim so a + // dereferenced symlink could orphan large audio blobs, and + // a directory symlink is the only way to terminate the + // recursion at the link boundary. + let link_target = std::fs::read_link(&entry_path)?; + #[cfg(unix)] + { + std::os::unix::fs::symlink(link_target, &target_path)?; + } + #[cfg(windows)] + { + // On Windows we have to pick file vs dir symlink at + // creation time. Probe the link target with full + // metadata (it resolves through the link) to decide. + // If the target is missing or unreadable, fall back to + // a file symlink — safer than panicking the migration. + let target_is_dir = std::fs::metadata(&entry_path) + .map(|m| m.is_dir()) + .unwrap_or(false); + if target_is_dir { + std::os::windows::fs::symlink_dir(link_target, &target_path)?; + } else { + std::os::windows::fs::symlink_file(link_target, &target_path)?; + } + } + } else if file_type.is_dir() { + copy_dir_recursive(&entry_path, &target_path)?; + } else if file_type.is_file() { + std::fs::copy(&entry_path, &target_path)?; + } else { + // Anything that is neither a symlink, a directory, nor a + // regular file lands here: on Unix that's FIFOs, sockets, + // and character / block device nodes. `std::fs::copy()` on + // a FIFO would block forever waiting for a writer, and on + // a device node would either fail unpredictably or attempt + // to read until the device's end-of-stream. Both turn a + // legacy-dir leftover into a silent migration hang. We + // refuse to cross the boundary and surface the path so + // the user can clean it up manually. The migration is + // re-runnable once the offending node is removed. + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "refusing to copy non-regular filesystem object during migration: {}", + entry_path.display() + ), + )); + } + } + Ok(()) +} + +fn rename_db_file_if_present(dir: &Path) -> Result { + let legacy_db = dir.join("magnotia.db"); + if !legacy_db.exists() { + return Ok(false); + } + let new_db = dir.join("lumotia.db"); + rename_or_copy_file(&legacy_db, &new_db)?; + Ok(true) +} + +fn rename_or_copy_file(from: &Path, to: &Path) -> Result<(), std::io::Error> { + match std::fs::rename(from, to) { + Ok(()) => Ok(()), + Err(e) if is_cross_device(&e) => { + std::fs::copy(from, to)?; + std::fs::remove_file(from)?; + Ok(()) + } + Err(e) => Err(e), } } #[cfg(test)] mod tests { - use super::AppPaths; + use super::*; use crate::types::ModelId; - use std::path::PathBuf; #[test] fn derives_all_paths_from_one_base() { let paths = AppPaths { - app_data_dir: PathBuf::from("/tmp/magnotia-test"), + app_data_dir: PathBuf::from("/tmp/lumotia-test"), }; assert_eq!( paths.database_path(), - PathBuf::from("/tmp/magnotia-test/magnotia.db") + PathBuf::from("/tmp/lumotia-test/lumotia.db") ); assert_eq!( paths.speech_model_dir(&ModelId::new("whisper-base-en")), - PathBuf::from("/tmp/magnotia-test/models/whisper-base-en") + PathBuf::from("/tmp/lumotia-test/models/whisper-base-en") ); assert_eq!( paths.llm_models_dir(), - PathBuf::from("/tmp/magnotia-test/models/llm") + PathBuf::from("/tmp/lumotia-test/models/llm") ); } + + fn unique_tmp(base: &str) -> PathBuf { + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("lumotia-paths-test-{base}-{pid}-{nanos}")) + } + + /// Helper: drive the migration with a single (legacy, target) pair + /// and return the (only) status it produced. Keeps existing tests + /// readable after the Option -> Vec API change. + fn migrate_one_pair_inner(pair: (PathBuf, PathBuf)) -> Result { + let mut statuses = migrate_legacy_data_dir_with_pairs(vec![pair])?; + assert_eq!( + statuses.len(), + 1, + "single-pair driver should yield exactly one status" + ); + Ok(statuses.pop().unwrap()) + } + + #[test] + fn migrate_with_legacy_present_renames_dir_and_db() { + let root = unique_tmp("legacy-present"); + let legacy = root.join("magnotia"); + let target = root.join("lumotia"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("magnotia.db"), b"sqlite-stub").unwrap(); + std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap(); + + let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); + + match result { + MigrationStatus::Migrated { + from, + to, + renamed_db, + } => { + assert_eq!(from, legacy); + assert_eq!(to, target); + assert!(renamed_db, "expected db file to be renamed"); + } + other => panic!("expected Migrated, got {other:?}"), + } + assert!(!legacy.exists(), "legacy dir should be gone"); + assert!(target.exists(), "new dir should exist"); + assert!(target.join("lumotia.db").exists(), "db at new name"); + assert!(!target.join("magnotia.db").exists(), "old db gone"); + assert!( + target.join("recordings.placeholder").exists(), + "other files preserved" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn migrate_with_both_present_returns_target_exists_and_preserves_lumotia() { + let root = unique_tmp("both-present"); + let legacy = root.join("magnotia"); + let target = root.join("lumotia"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("lumotia.db"), b"new-data").unwrap(); + std::fs::write(legacy.join("magnotia.db"), b"legacy-data").unwrap(); + + let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); + + assert_eq!( + result, + MigrationStatus::TargetAlreadyExists { + target: target.clone() + } + ); + assert!(legacy.exists(), "legacy dir preserved"); + assert!(target.exists(), "new dir preserved"); + assert_eq!( + std::fs::read(target.join("lumotia.db")).unwrap(), + b"new-data".to_vec(), + "lumotia.db not overwritten" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn migrate_with_neither_present_returns_no_legacy() { + let result = migrate_legacy_data_dir_with_pairs(Vec::new()).expect("migrate ok"); + + assert_eq!(result, vec![MigrationStatus::NoLegacyFound]); + } + + #[test] + fn migrate_with_legacy_present_but_no_db_inside() { + let root = unique_tmp("legacy-no-db"); + let legacy = root.join("magnotia"); + let target = root.join("lumotia"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap(); + + let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); + + match result { + MigrationStatus::Migrated { renamed_db, .. } => { + assert!(!renamed_db, "no db to rename"); + } + other => panic!("expected Migrated, got {other:?}"), + } + assert!(target.exists()); + assert!(target.join("recordings.placeholder").exists()); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn migrate_preserves_dot_home_to_dot_home_convention() { + // Regression for B2 from Phase 5 QC: dot-home legacy must land in + // dot-home target, not XDG target. + let root = unique_tmp("convention"); + let legacy = root.join(".magnotia"); + let target = root.join(".lumotia"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("magnotia.db"), b"data").unwrap(); + + let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); + + assert!(matches!(result, MigrationStatus::Migrated { .. })); + assert!(target.exists()); + assert!(target.join("lumotia.db").exists()); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn copy_dir_recursive_preserves_nested_files_and_directories() { + // Regression for Codex Blocker 11: EXDEV fallback path uses + // copy_dir_recursive. Verify it preserves directory structure, + // file contents, and arbitrary depth. + let root = unique_tmp("copy-recursive"); + let src = root.join("legacy"); + let dst = root.join("new"); + std::fs::create_dir_all(src.join("recordings/2026-05")).unwrap(); + std::fs::create_dir_all(src.join("models/whisper-base-en")).unwrap(); + std::fs::write(src.join("magnotia.db"), b"sqlite-bytes").unwrap(); + std::fs::write(src.join("recordings/2026-05/clip-001.wav"), b"wav-bytes").unwrap(); + std::fs::write(src.join("models/whisper-base-en/manifest.json"), b"{}").unwrap(); + + copy_dir_recursive(&src, &dst).expect("copy ok"); + + assert_eq!( + std::fs::read(dst.join("magnotia.db")).unwrap(), + b"sqlite-bytes" + ); + assert_eq!( + std::fs::read(dst.join("recordings/2026-05/clip-001.wav")).unwrap(), + b"wav-bytes" + ); + assert!(dst.join("models/whisper-base-en/manifest.json").exists()); + // Source still present — copy_dir_recursive does not delete. + assert!(src.exists()); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn rename_or_copy_tree_succeeds_on_same_filesystem() { + // Smoke for the happy path: same-filesystem case uses rename + // and leaves no source behind. + let root = unique_tmp("rename-tree"); + let src = root.join("legacy"); + let dst = root.join("new"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("magnotia.db"), b"data").unwrap(); + + rename_or_copy_tree(&src, &dst).expect("rename ok"); + + assert!(!src.exists(), "source removed"); + assert!(dst.exists(), "destination present"); + assert_eq!(std::fs::read(dst.join("magnotia.db")).unwrap(), b"data"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn is_cross_device_classifies_exdev_error_kind() { + // Synthesise an io::Error tagged with CrossesDevices and ensure + // the classifier returns true. This is the path that triggers + // the copy-then-delete fallback in rename_or_copy_tree on Rust + // 1.85+ toolchains. + let err = std::io::Error::new(std::io::ErrorKind::CrossesDevices, "test exdev"); + assert!(is_cross_device(&err)); + + let unrelated = std::io::Error::new(std::io::ErrorKind::NotFound, "test notfound"); + assert!(!is_cross_device(&unrelated)); + } + + #[cfg(unix)] + #[test] + fn is_cross_device_classifies_raw_exdev_on_unix() { + // Belt-and-braces: ensure raw errno 18 (EXDEV on Linux) is + // classified as cross-device even if the ErrorKind doesn't + // map to CrossesDevices (older toolchains, future kernel + // surprises). + let err = std::io::Error::from_raw_os_error(18); + assert!(is_cross_device(&err)); + } + + // ------------------------------------------------------------------ + // Defect A regression tests: multi-legacy-candidate + ambiguity guard + // ------------------------------------------------------------------ + + #[test] + fn migrate_handles_both_dot_home_and_xdg() { + // Reproduces the multi-legacy orphan scenario: a Linux user with + // BOTH `~/.magnotia` and `~/.local/share/magnotia` on disk. The + // old code returned `Option<(legacy, target)>` and short-circuited + // on the dot-home variant, leaving the XDG legacy orphaned. The + // new driver loops over the Vec and migrates every candidate. + let root = unique_tmp("both-legacy"); + let dot_legacy = root.join(".magnotia"); + let dot_target = root.join(".lumotia"); + let xdg_legacy = root.join(".local/share/magnotia"); + let xdg_target = root.join(".local/share/lumotia"); + std::fs::create_dir_all(&dot_legacy).unwrap(); + std::fs::create_dir_all(&xdg_legacy).unwrap(); + std::fs::write(dot_legacy.join("marker"), b"dot-home").unwrap(); + std::fs::write(xdg_legacy.join("marker"), b"xdg").unwrap(); + + let statuses = migrate_legacy_data_dir_with_pairs(vec![ + (dot_legacy.clone(), dot_target.clone()), + (xdg_legacy.clone(), xdg_target.clone()), + ]) + .expect("migrate ok"); + + assert_eq!( + statuses.len(), + 2, + "expected one status per legacy candidate" + ); + for s in &statuses { + assert!( + matches!(s, MigrationStatus::Migrated { .. }), + "expected Migrated, got {s:?}" + ); + } + assert!(!dot_legacy.exists(), "dot-home legacy should be gone"); + assert!(!xdg_legacy.exists(), "XDG legacy should be gone"); + assert!(dot_target.exists(), "dot-home target should exist"); + assert!(xdg_target.exists(), "XDG target should exist"); + assert_eq!( + std::fs::read(dot_target.join("marker")).unwrap(), + b"dot-home".to_vec(), + "dot-home content preserved" + ); + assert_eq!( + std::fs::read(xdg_target.join("marker")).unwrap(), + b"xdg".to_vec(), + "XDG content preserved" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn resolve_app_data_dir_refuses_on_multiple_targets() { + // Reproduces the stray-dot-home orphan scenario: after a partial + // migration the user may end up with BOTH `~/.lumotia` and + // `~/.local/share/lumotia` on disk. Picking one silently is + // worse than failing fast, so the strict resolver must error + // with both paths surfaced for manual consolidation. + // + // We override HOME so the strict resolver scans inside our + // tempdir, then assert it returns Err with both paths named. + let root = unique_tmp("ambiguous-target"); + let fake_home = root.join("home"); + std::fs::create_dir_all(&fake_home).unwrap(); + let dot = fake_home.join(".lumotia"); + let xdg_default = fake_home.join(".local/share/lumotia"); + std::fs::create_dir_all(&dot).unwrap(); + std::fs::create_dir_all(&xdg_default).unwrap(); + + // Serialise env mutation: HOME / XDG_DATA_HOME are process-global, + // and other tests in this module rely on them being unchanged. + // We restore the previous values before returning. + let prev_home = std::env::var_os("HOME"); + let prev_xdg = std::env::var_os("XDG_DATA_HOME"); + // SAFETY: tests in this module that read HOME serialise on this + // exact pattern (set, call, restore) and the process is otherwise + // single-threaded inside a #[test] body. + std::env::set_var("HOME", &fake_home); + std::env::remove_var("XDG_DATA_HOME"); + + let result = resolve_app_data_dir_strict(); + + // Restore env BEFORE asserting so a panic doesn't poison + // subsequent tests. + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + if let Some(v) = prev_xdg { + std::env::set_var("XDG_DATA_HOME", v); + } + + let err = result.expect_err("expected ambiguity error"); + assert!( + err.candidates.iter().any(|p| p == &dot), + "error must name dot-home candidate: {err}" + ); + assert!( + err.candidates.iter().any(|p| p == &xdg_default), + "error must name XDG default candidate: {err}" + ); + let msg = err.to_string(); + assert!( + msg.contains("ambiguous"), + "message should flag ambiguity: {msg}" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + // ------------------------------------------------------------------ + // Defect B regression tests: copy_dir_recursive symlink loop + // ------------------------------------------------------------------ + + #[cfg(unix)] + #[test] + fn copy_dir_recursive_does_not_loop_on_self_referential_dir_symlink() { + // The original code used `entry.metadata()` which follows + // symlinks, so a directory symlink reported is_dir==true and + // recursed unconditionally. A self-referential dir symlink would + // then loop until the disk filled. Use file_type() (which does + // NOT follow symlinks), branch on is_symlink() FIRST, and + // recreate the link instead of recursing through it. + let root = unique_tmp("symlink-self"); + let src = root.join("src"); + let dst = root.join("dst"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("regular-file"), b"hello").unwrap(); + // Self-reference: src/oops -> src. + std::os::unix::fs::symlink(&src, src.join("oops")).unwrap(); + + copy_dir_recursive(&src, &dst).expect("copy must terminate, not loop"); + + // The regular file should have been copied. + assert_eq!(std::fs::read(dst.join("regular-file")).unwrap(), b"hello"); + // The self-reference should have been recreated as a symlink, + // NOT as a directory full of recursive copies. + let oops = dst.join("oops"); + let oops_meta = std::fs::symlink_metadata(&oops).expect("oops should exist"); + assert!( + oops_meta.file_type().is_symlink(), + "dst/oops must be a symlink, not a recursive directory copy" + ); + // And the link target must be preserved verbatim. + let link_target = std::fs::read_link(&oops).unwrap(); + assert_eq!(link_target, src, "symlink target should be preserved"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[cfg(unix)] + #[test] + fn copy_dir_recursive_preserves_directory_symlinks() { + // A directory symlink to a real sibling dir must be recreated as + // a symlink in dst (preserving the link-shape), not dereferenced + // into a recursive copy of the sibling's contents. + let root = unique_tmp("symlink-dir"); + let src = root.join("src"); + let sibling = root.join("sibling"); + let dst = root.join("dst"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&sibling).unwrap(); + std::fs::write(sibling.join("payload"), b"sibling-data").unwrap(); + // src/link -> sibling (directory symlink). + std::os::unix::fs::symlink(&sibling, src.join("link")).unwrap(); + + copy_dir_recursive(&src, &dst).expect("copy ok"); + + let dst_link = dst.join("link"); + let meta = std::fs::symlink_metadata(&dst_link).expect("dst/link should exist"); + assert!( + meta.file_type().is_symlink(), + "dst/link must remain a symlink, not be replaced with a directory copy" + ); + // Following the link should still resolve to sibling content; + // the link target must be preserved verbatim. + let link_target = std::fs::read_link(&dst_link).unwrap(); + assert_eq!(link_target, sibling, "symlink target should be preserved"); + // And we must NOT have written sibling/payload into dst/link/. + // (If link is a symlink, reading dst/link/payload would follow + // it back to sibling/payload, so check on-disk shape instead.) + let entries: Vec<_> = std::fs::read_dir(&dst).unwrap().collect(); + let dst_link_entry = entries + .iter() + .find_map(|e| e.as_ref().ok()) + .filter(|e| e.file_name() == "link"); + if let Some(e) = dst_link_entry { + assert!( + e.file_type().unwrap().is_symlink(), + "directory entry for dst/link must report symlink" + ); + } + + std::fs::remove_dir_all(&root).ok(); + } + + // ------------------------------------------------------------------ + // Adversarial probes: hostile filesystem objects inside the legacy + // tree that could turn a benign cross-device migration into a hang, + // a silent data loss, or an unhelpful panic. These exercise the + // copy_dir_recursive fall-through after the symlink and directory + // branches have been ruled out. Same-filesystem rename via + // `std::fs::rename` is atomic and bypasses these paths entirely; we + // only need to defend the EXDEV copy fallback. + // ------------------------------------------------------------------ + + #[cfg(unix)] + #[test] + fn copy_dir_recursive_rejects_fifo_in_legacy_tree_without_hanging() { + // A FIFO inside the legacy tree must NOT cause copy_dir_recursive + // to block on std::fs::copy (open-for-read on a FIFO with no + // writer blocks indefinitely). The hardened branch surfaces an + // Unsupported error naming the offending path, leaving the + // partial destination on disk for the user to consult before + // retrying. + let root = unique_tmp("fifo-rejected"); + let src = root.join("legacy"); + let dst = root.join("new"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("regular.txt"), b"normal-data").unwrap(); + + let fifo_path = src.join("debug-pipe"); + // mkfifo via the system binary keeps the test free of an extra + // libc dev-dependency. The migration only needs the FIFO to be + // present on disk so file_type().is_fifo() returns true. + let status = std::process::Command::new("mkfifo") + .arg(&fifo_path) + .status() + .expect("mkfifo invocation must run on a unix test host"); + assert!(status.success(), "mkfifo must succeed"); + + // Bound the test against the regression: if a future refactor + // re-introduces the std::fs::copy fall-through, the FIFO read + // would hang forever and stall CI. We run copy_dir_recursive + // on a worker thread and require it to return within a tight + // budget; a slow CI host gets 5 seconds, which is many orders + // of magnitude above the expected ~ms return. + let src_owned = src.clone(); + let dst_owned = dst.clone(); + let handle = std::thread::spawn(move || copy_dir_recursive(&src_owned, &dst_owned)); + let start = std::time::Instant::now(); + let result = loop { + if handle.is_finished() { + break handle.join().expect("worker thread must not panic"); + } + if start.elapsed() > std::time::Duration::from_secs(5) { + panic!( + "copy_dir_recursive hung on a FIFO inside the legacy tree; \ + suspected regression in the non-regular fall-through guard" + ); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + + let err = result.expect_err("FIFO inside legacy tree must surface an error"); + assert_eq!(err.kind(), std::io::ErrorKind::Unsupported); + let msg = err.to_string(); + assert!( + msg.contains("debug-pipe"), + "error must name the offending path: {msg}" + ); + + // The partial destination may or may not contain the regular + // file depending on read_dir iteration order; we don't assert + // either way. What matters is that the migration returns an + // error rather than blocking forever. + std::fs::remove_dir_all(&root).ok(); + } + + #[cfg(unix)] + #[test] + fn copy_dir_recursive_surfaces_permission_error_on_unreadable_file() { + // A legacy file with mode 0000 inside the tree must cause + // copy_dir_recursive to fail loud with PermissionDenied, not + // silently skip the file (which would orphan user data). The + // user can chmod the file and retry. + use std::os::unix::fs::PermissionsExt; + + let root = unique_tmp("unreadable-file"); + let src = root.join("legacy"); + let dst = root.join("new"); + std::fs::create_dir_all(&src).unwrap(); + let locked = src.join("locked.db"); + std::fs::write(&locked, b"sensitive").unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + + // Belt-and-braces against test-running-as-root: root bypasses + // DAC permissions and would silently succeed, masking the + // regression. Skip the assertion in that case so the test is + // honest about what it proved. + let running_as_root = effective_uid() == 0; + + let result = copy_dir_recursive(&src, &dst); + + // Restore permissions before TempDir cleanup, regardless of + // the test outcome, so the tempdir teardown doesn't itself + // hit EACCES. + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).ok(); + + if running_as_root { + // Document the bypass; the test still has value in CI + // where the runner is non-root. + assert!( + result.is_ok() || result.is_err(), + "test result intentionally not asserted under euid 0" + ); + } else { + let err = result.expect_err("unreadable file must surface an error"); + assert_eq!( + err.kind(), + std::io::ErrorKind::PermissionDenied, + "expected PermissionDenied, got {err:?}" + ); + } + + std::fs::remove_dir_all(&root).ok(); + } + + /// Read the effective uid by inspecting a freshly-created file's + /// owner. Used by the permission-denied probe to skip its core + /// assertion when the test host runs as root (root bypasses DAC). + /// Direct over a libc dev-dependency for one helper. + #[cfg(unix)] + fn effective_uid() -> u32 { + use std::os::unix::fs::MetadataExt; + let tmp = std::env::temp_dir().join(format!("euid-probe-{}", std::process::id())); + std::fs::write(&tmp, b"x").unwrap(); + let uid = std::fs::metadata(&tmp).unwrap().uid(); + std::fs::remove_file(&tmp).ok(); + uid + } + + #[cfg(unix)] + #[test] + fn copy_dir_recursive_preserves_dangling_symlink_target() { + // A legacy symlink pointing at a since-deleted target must be + // recreated as a dangling symlink at the destination — NOT + // dereferenced (which would fail) and NOT skipped (which would + // silently drop a piece of the user's directory shape). + let root = unique_tmp("dangling-symlink"); + let src = root.join("legacy"); + let dst = root.join("new"); + std::fs::create_dir_all(&src).unwrap(); + std::os::unix::fs::symlink("/no/such/path/ever", src.join("orphan")).unwrap(); + + copy_dir_recursive(&src, &dst).expect("dangling symlink must not abort the copy"); + + let orphan = dst.join("orphan"); + let meta = std::fs::symlink_metadata(&orphan).expect("orphan must exist as a link"); + assert!( + meta.file_type().is_symlink(), + "dst/orphan must be a symlink, not a regular file or directory" + ); + let link_target = std::fs::read_link(&orphan).unwrap(); + assert_eq!( + link_target, + std::path::PathBuf::from("/no/such/path/ever"), + "symlink target must be preserved verbatim" + ); + + std::fs::remove_dir_all(&root).ok(); + } } diff --git a/crates/core/src/power.rs b/crates/core/src/power.rs index 3945c8d..1d059dd 100644 --- a/crates/core/src/power.rs +++ b/crates/core/src/power.rs @@ -101,7 +101,7 @@ pub(crate) fn force_set_cache(state: PowerState) { /// /// Resolution order (highest to lowest priority): /// 1. In-process test override (set via `with_override` from unit tests). -/// 2. `MAGNOTIA_POWER_STATE_OVERRIDE` env var (`ac` | `battery` | `unknown`, +/// 2. `LUMOTIA_POWER_STATE_OVERRIDE` env var (`ac` | `battery` | `unknown`, /// case-insensitive). Used by `thread_sweep.rs` integration tests. /// 3. Linux: `parse_power_state_from_dir("/sys/class/power_supply")`. /// 4. macOS / Windows / other: `Unknown`. @@ -136,7 +136,7 @@ pub fn probe_power_state() -> PowerState { } fn env_override() -> Option { - let raw = std::env::var("MAGNOTIA_POWER_STATE_OVERRIDE").ok()?; + let raw = std::env::var("LUMOTIA_POWER_STATE_OVERRIDE").ok()?; match raw.trim().to_ascii_lowercase().as_str() { "ac" => Some(PowerState::OnAc), "battery" => Some(PowerState::OnBattery), @@ -293,21 +293,21 @@ mod tests { // env-var path tested in isolation under TEST_LOCK so it // doesn't race with the in-process override tests. with_override(None, || { - std::env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", "battery"); + std::env::set_var("LUMOTIA_POWER_STATE_OVERRIDE", "battery"); assert_eq!(probe_power_state(), PowerState::OnBattery); - std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE"); + std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE"); }); } #[test] fn env_var_override_garbage_falls_through() { with_override(None, || { - std::env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", "nonsense"); + std::env::set_var("LUMOTIA_POWER_STATE_OVERRIDE", "nonsense"); // Garbage value falls through to the platform probe. // We can't assert the platform result so just assert it // doesn't panic. let _ = probe_power_state(); - std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE"); + std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE"); }); } diff --git a/crates/core/src/recommendation.rs b/crates/core/src/recommendation.rs index 4790d04..68f3de3 100644 --- a/crates/core/src/recommendation.rs +++ b/crates/core/src/recommendation.rs @@ -184,7 +184,7 @@ mod tests { fn parakeet_is_top_recommendation_when_hardware_supports_it() { // Any machine that fits Parakeet in RAM should see it ranked first — // Parakeet-TDT is English-only but beats Whisper on English at lower - // latency, so it's Magnotia's default recommendation when eligible. + // latency, so it's Lumotia's default recommendation when eligible. // (Users on non-English languages adjust manually — handled at the // settings-UI level, not at the scoring level for now.) let profile = profile_with_ram(Megabytes(16384)); diff --git a/crates/core/src/tuning.rs b/crates/core/src/tuning.rs index 45c0e36..b871891 100644 --- a/crates/core/src/tuning.rs +++ b/crates/core/src/tuning.rs @@ -16,7 +16,7 @@ pub const MIN_INFERENCE_THREADS: usize = 2; /// 8 is a conservative ceiling that leaves <5% on the table for /// big-iron desktops while keeping consumer 6c/12t laptops out of /// contention territory. Users can override at runtime via -/// MAGNOTIA_INFERENCE_THREADS. +/// LUMOTIA_INFERENCE_THREADS. pub const MAX_INFERENCE_THREADS: usize = 8; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -45,13 +45,13 @@ fn log_seen() -> &'static Mutex> { /// the battery and GPU-offload heuristics. /// /// Resolution order: -/// 1. `MAGNOTIA_INFERENCE_THREADS=N` — absolute bypass, returns N. +/// 1. `LUMOTIA_INFERENCE_THREADS=N` — absolute bypass, returns N. /// 2. base = num_cpus::get_physical() (fallback: available_parallelism). /// 3. on battery → base /= 2. /// 4. gpu_offloaded → base = min(base, gpu_floor(workload)). /// 5. clamp to [MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS]. pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize { - if let Ok(s) = std::env::var("MAGNOTIA_INFERENCE_THREADS") { + if let Ok(s) = std::env::var("LUMOTIA_INFERENCE_THREADS") { if let Ok(n) = s.parse::() { if n > 0 { return n; @@ -89,7 +89,7 @@ pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize if let Ok(mut seen) = log_seen().lock() { if seen.insert(key) { tracing::info!( - target: "magnotia_core::tuning", + target: "lumotia_core::tuning", threads = final_value, workload = ?workload, gpu_offloaded, @@ -107,7 +107,7 @@ pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize mod tests { use super::*; - /// Serialises tests that read/write `MAGNOTIA_INFERENCE_THREADS` so + /// Serialises tests that read/write `LUMOTIA_INFERENCE_THREADS` so /// they don't race under cargo's parallel test runner. /// Mirrors the pattern used by `power::with_override`. fn with_thread_env_lock(body: impl FnOnce() -> R) -> R { @@ -126,7 +126,7 @@ mod tests { // We can't pin physical exactly without mocking num_cpus; just // assert the result is in range. with_thread_env_lock(|| { - std::env::remove_var("MAGNOTIA_INFERENCE_THREADS"); + std::env::remove_var("LUMOTIA_INFERENCE_THREADS"); let n = inference_thread_count(Workload::Llm, false); assert!( (MIN_INFERENCE_THREADS..=MAX_INFERENCE_THREADS).contains(&n), @@ -138,10 +138,10 @@ mod tests { #[test] fn env_var_bypasses_clamps() { with_thread_env_lock(|| { - std::env::set_var("MAGNOTIA_INFERENCE_THREADS", "10"); + std::env::set_var("LUMOTIA_INFERENCE_THREADS", "10"); let n = inference_thread_count(Workload::Llm, true); assert_eq!(n, 10); - std::env::remove_var("MAGNOTIA_INFERENCE_THREADS"); + std::env::remove_var("LUMOTIA_INFERENCE_THREADS"); }); } @@ -153,7 +153,7 @@ mod tests { #[test] fn battery_halves_thread_count() { with_thread_env_lock(|| { - std::env::remove_var("MAGNOTIA_INFERENCE_THREADS"); + std::env::remove_var("LUMOTIA_INFERENCE_THREADS"); // Measure on battery, then on AC — sequential, not nested, // to avoid re-entrant deadlock on power::TEST_LOCK. let on_battery = crate::power::with_override(Some(PowerState::OnBattery), || { @@ -176,7 +176,7 @@ mod tests { #[test] fn gpu_offload_clamps_llm_to_floor() { with_thread_env_lock(|| { - std::env::remove_var("MAGNOTIA_INFERENCE_THREADS"); + std::env::remove_var("LUMOTIA_INFERENCE_THREADS"); crate::power::with_override(Some(PowerState::OnAc), || { let n = inference_thread_count(Workload::Llm, true); assert!( @@ -191,7 +191,7 @@ mod tests { #[test] fn gpu_offload_clamps_whisper_to_floor() { with_thread_env_lock(|| { - std::env::remove_var("MAGNOTIA_INFERENCE_THREADS"); + std::env::remove_var("LUMOTIA_INFERENCE_THREADS"); crate::power::with_override(Some(PowerState::OnAc), || { let n = inference_thread_count(Workload::Whisper, true); // Whisper floor is 4 on machines with >=4 physical cores; @@ -213,7 +213,7 @@ mod tests { #[test] fn gpu_offload_off_does_not_clamp_below_battery_calc() { with_thread_env_lock(|| { - std::env::remove_var("MAGNOTIA_INFERENCE_THREADS"); + std::env::remove_var("LUMOTIA_INFERENCE_THREADS"); // Sequential measurements; with_override is non-reentrant. crate::power::with_override(Some(PowerState::OnAc), || { let no_gpu = inference_thread_count(Workload::Llm, false); @@ -230,7 +230,7 @@ mod tests { // wired. This is covered by the other tests too, but kept // explicitly to document the behaviour. with_thread_env_lock(|| { - std::env::remove_var("MAGNOTIA_INFERENCE_THREADS"); + std::env::remove_var("LUMOTIA_INFERENCE_THREADS"); crate::power::with_override(Some(PowerState::OnBattery), || { let _ = inference_thread_count(Workload::Llm, true); let _ = inference_thread_count(Workload::Whisper, false); diff --git a/crates/hotkey/Cargo.toml b/crates/hotkey/Cargo.toml index ae58f4c..111601a 100644 --- a/crates/hotkey/Cargo.toml +++ b/crates/hotkey/Cargo.toml @@ -1,11 +1,13 @@ [package] -name = "magnotia-hotkey" -version = "0.1.0" -edition = "2021" -description = "Wayland-compatible global hotkey listener for Magnotia — evdev backend with device hotplug" +name = "lumotia-hotkey" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +description = "Wayland-compatible global hotkey listener for Lumotia — evdev backend with device hotplug" [dependencies] -magnotia-core = { path = "../core" } +lumotia-core = { path = "../core" } tokio = { version = "1", features = ["rt", "sync", "macros", "time"] } serde = { version = "1", features = ["derive"] } tracing = "0.1" @@ -14,3 +16,9 @@ tracing = "0.1" evdev = { version = "0.12", features = ["tokio"] } notify = { version = "7", default-features = false, features = ["macos_fsevent"] } nix = { version = "0.29", features = ["fs"] } + +[dev-dependencies] +# `rt-multi-thread` enables `#[tokio::test(flavor = "multi_thread")]` used by +# the supervisor + listener lifecycle regression tests. Without it the +# concurrency leak coverage cannot exercise real parallelism. +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] } diff --git a/crates/hotkey/src/lib.rs b/crates/hotkey/src/lib.rs index 21b6662..561604b 100644 --- a/crates/hotkey/src/lib.rs +++ b/crates/hotkey/src/lib.rs @@ -1,4 +1,4 @@ -//! Wayland-compatible global hotkey listener for Magnotia. +//! Wayland-compatible global hotkey listener for Lumotia. //! //! On Linux, reads `/dev/input/event*` devices via the `evdev` crate to capture //! global hotkeys without any display-server dependency. This works on both X11 @@ -8,11 +8,14 @@ //! On non-Linux platforms, this crate is a no-op — the Tauri global-shortcut //! plugin handles hotkeys there. //! -//! Architecture stolen from oddlama/whisper-overlay and adapted for Magnotia. +//! Architecture stolen from oddlama/whisper-overlay and adapted for Lumotia. #[cfg(target_os = "linux")] mod linux; +#[cfg(target_os = "linux")] +mod supervisor; + #[cfg(target_os = "linux")] pub use linux::*; diff --git a/crates/hotkey/src/linux.rs b/crates/hotkey/src/linux.rs index 48649f8..28fb3da 100644 --- a/crates/hotkey/src/linux.rs +++ b/crates/hotkey/src/linux.rs @@ -8,8 +8,19 @@ //! - Device hotplug via `notify` watching `/dev/input/` //! - Retry loop for udev permission propagation on new devices //! - Per-device async event streams +//! +//! ## Lifecycle +//! +//! Every task this module spawns is owned by a +//! [`crate::supervisor::SupervisorHandle`] living inside the +//! [`EvdevHotkeyListener`]. On `stop()`, the supervisor sends a broadcast +//! shutdown signal and awaits every `JoinHandle` with a bounded timeout, +//! so a reconfigure cannot leave orphaned listeners alive (which would +//! otherwise hold `event_tx` clones forever and emit duplicate events to +//! the frontend). See `supervisor.rs` for the supervisor and +//! `tests/listener_lifecycle.rs` for the regression tests. -use std::collections::HashSet; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -17,6 +28,7 @@ use evdev::{AttributeSetRef, Device, InputEventKind, Key}; use notify::{recommended_watcher, EventKind, RecursiveMode, Watcher}; use tokio::sync::{mpsc, watch, Mutex}; +use crate::supervisor::SupervisorHandle; use crate::HotkeyCombo; /// Events emitted by the hotkey listener. @@ -28,12 +40,27 @@ pub enum HotkeyEvent { Released, } +/// Shared map of attached evdev devices. Keyed by path so attach is +/// idempotent. Membership-only marker — the actual `JoinHandle` for each +/// device listener task lives in the supervisor. Insert-before-spawn +/// under one mutex hold closes the TOCTOU window the previous design had. +type TrackedDevices = Arc>>; + /// Manages evdev device listeners and hotplug detection. +/// +/// All spawned tasks are owned by an internal +/// [`SupervisorHandle`]. On `stop()` (or `Drop`) every task receives a +/// broadcast shutdown signal and is joined with a per-task timeout so a +/// reconfigure cannot leak listeners. pub struct EvdevHotkeyListener { /// Send a new hotkey config to all listener tasks. hotkey_tx: watch::Sender>, - /// Signals all tasks to shut down. - shutdown_tx: mpsc::Sender<()>, + /// Tracks every spawned task. Cloned into spawn sites so per-device + /// retry tasks can register their own children. + supervisor: SupervisorHandle, + /// Set to `true` once `stop()` has run so `Drop` skips its + /// best-effort shutdown signal. + stopped: bool, } impl EvdevHotkeyListener { @@ -43,101 +70,71 @@ impl EvdevHotkeyListener { /// The listener spawns: /// 1. One async task per input device that has the target key /// 2. A watcher task that detects new devices via inotify on `/dev/input/` - pub fn start(combo: HotkeyCombo, event_tx: mpsc::Sender) -> Self { + pub async fn start(combo: HotkeyCombo, event_tx: mpsc::Sender) -> Self { let (hotkey_tx, hotkey_rx) = watch::channel(Some(combo)); - let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); + let tracked: TrackedDevices = Arc::new(Mutex::new(HashMap::new())); + let supervisor = SupervisorHandle::new(); - let tracked = Arc::new(Mutex::new(HashSet::::new())); - - // Spawn initial device listeners - let hotkey_rx_clone = hotkey_rx.clone(); - let event_tx_clone = event_tx.clone(); - let tracked_clone = tracked.clone(); - tokio::spawn(async move { - scan_and_attach(&hotkey_rx_clone, &event_tx_clone, &tracked_clone).await; - }); - - // Spawn hotplug watcher - let hotkey_rx_hotplug = hotkey_rx.clone(); - let event_tx_hotplug = event_tx.clone(); - let tracked_hotplug = tracked.clone(); - tokio::spawn(async move { - let (notify_tx, mut notify_rx) = mpsc::channel::(32); - - // notify watcher runs on a blocking thread internally. - // If inotify itself is unavailable (rare: minimal containers, - // some BSDs misconfigured as Linux) we degrade to "no - // hotplug detection" rather than panicking the task — the - // initial scan_and_attach pass above still picks up all - // devices that exist at startup. - let _watcher = { - let notify_tx = notify_tx.clone(); - let watcher = recommended_watcher(move |res: Result| { - if let Ok(event) = res { - if matches!(event.kind, EventKind::Create(_)) { - for path in event.paths { - if is_event_device(&path) { - let _ = notify_tx.blocking_send(path); - } - } - } - } - }); - match watcher { - Ok(mut w) => { - match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) { - Ok(()) => Some(w), - Err(e) => { - tracing::warn!( - error = %e, - "cannot watch /dev/input; hotplug detection disabled, \ - devices present at startup still work" - ); - None - } - } - } - Err(e) => { - tracing::warn!( - error = %e, - "cannot create inotify watcher; hotplug detection disabled" - ); - None - } - } - }; - - loop { + // Spawn initial scanner. Walks /dev/input once and attaches every + // matching device. After it completes the hotplug watcher (below) + // is responsible for keeping the attachment set in sync. + { + let hotkey_rx = hotkey_rx.clone(); + let event_tx = event_tx.clone(); + let tracked = tracked.clone(); + let supervisor_inner = supervisor.clone(); + let mut shutdown_rx = supervisor.subscribe(); + let scanner_handle = tokio::spawn(async move { tokio::select! { - Some(path) = notify_rx.recv() => { - // Retry opening with backoff — udev permissions propagate - // asynchronously after device creation (whisper-overlay pattern) - let hotkey_rx = hotkey_rx_hotplug.clone(); - let event_tx = event_tx_hotplug.clone(); - let tracked = tracked_hotplug.clone(); - tokio::spawn(async move { - for attempt in 0..5 { - if attempt > 0 { - tokio::time::sleep( - std::time::Duration::from_secs(1) - ).await; - } - if try_attach_device( - &path, &hotkey_rx, &event_tx, &tracked, - ).await { - break; - } - } - }); + _ = scan_and_attach( + &hotkey_rx, + &event_tx, + &tracked, + &supervisor_inner, + ) => {} + _ = shutdown_rx.recv() => { + tracing::debug!( + target: "lumotia_hotkey", + "scanner received shutdown signal mid-scan" + ); } - _ = shutdown_rx.recv() => break, } - } - }); + }); + supervisor.register("scanner", scanner_handle).await; + } + + // Spawn hotplug watcher. Hands the supervisor handle through so + // it can register retry tasks it spawns. + { + let hotkey_rx = hotkey_rx.clone(); + let event_tx = event_tx.clone(); + let tracked = tracked.clone(); + let supervisor_inner = supervisor.clone(); + let mut shutdown_rx = supervisor.subscribe(); + let hotplug_handle = tokio::spawn(async move { + run_hotplug_watcher( + hotkey_rx, + event_tx, + tracked, + supervisor_inner, + &mut shutdown_rx, + ) + .await; + }); + supervisor.register("hotplug", hotplug_handle).await; + } + + let task_count = supervisor.task_count().await; + tracing::info!( + target: "lumotia_hotkey", + task_count = task_count, + "supervisor started" + ); Self { hotkey_tx, - shutdown_tx, + supervisor, + stopped: false, } } @@ -148,9 +145,121 @@ impl EvdevHotkeyListener { } /// Stop all listeners and clean up. - pub async fn stop(&self) { + /// + /// Consumes the listener so it cannot be reused. Awaits every + /// supervised task with a per-task timeout (see + /// [`SupervisorHandle::shutdown`]); a stuck task is logged and + /// detached rather than blocking the caller indefinitely. + pub async fn stop(mut self) { + // Signal None first so device listeners exit their loop cleanly + // without waiting for the broadcast subscription select arm. let _ = self.hotkey_tx.send(None); - let _ = self.shutdown_tx.send(()).await; + self.supervisor.shutdown().await; + self.stopped = true; + } +} + +/// Best-effort shutdown on drop. Async drop isn't available in stable +/// Rust, so we only fire the broadcast — we cannot await JoinHandles +/// here. Tasks subscribed to the broadcast see the signal and exit +/// cooperatively; their JoinHandles detach but the runtime reclaims them +/// once they finish. The intended path is always explicit `stop().await` +/// before drop. +impl Drop for EvdevHotkeyListener { + fn drop(&mut self) { + if !self.stopped { + self.supervisor.signal_shutdown_nonblocking(); + let _ = self.hotkey_tx.send(None); + } + } +} + +/// Hotplug watcher loop. Listens for inotify events on `/dev/input/` +/// and dispatches a retry task per new device path. Cooperatively +/// shuts down on broadcast. +async fn run_hotplug_watcher( + hotkey_rx: watch::Receiver>, + event_tx: mpsc::Sender, + tracked: TrackedDevices, + supervisor: SupervisorHandle, + shutdown_rx: &mut tokio::sync::broadcast::Receiver<()>, +) { + let (notify_tx, mut notify_rx) = mpsc::channel::(32); + + // notify watcher runs on a blocking thread internally. + // If inotify itself is unavailable (rare: minimal containers, + // some BSDs misconfigured as Linux) we degrade to "no + // hotplug detection" rather than panicking the task — the + // initial scan_and_attach pass above still picks up all + // devices that exist at startup. + let _watcher = { + let notify_tx = notify_tx.clone(); + let watcher = recommended_watcher(move |res: Result| { + if let Ok(event) = res { + if matches!(event.kind, EventKind::Create(_)) { + for path in event.paths { + if is_event_device(&path) { + let _ = notify_tx.blocking_send(path); + } + } + } + } + }); + match watcher { + Ok(mut w) => match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) { + Ok(()) => Some(w), + Err(e) => { + tracing::warn!( + error = %e, + "cannot watch /dev/input; hotplug detection disabled, \ + devices present at startup still work" + ); + None + } + }, + Err(e) => { + tracing::warn!( + error = %e, + "cannot create inotify watcher; hotplug detection disabled" + ); + None + } + } + }; + + loop { + tokio::select! { + Some(path) = notify_rx.recv() => { + // Retry opening with backoff — udev permissions propagate + // asynchronously after device creation (whisper-overlay pattern). + // The retry task subscribes to the broadcast so it exits + // promptly on stop() even if it's mid-backoff. + let hotkey_rx = hotkey_rx.clone(); + let event_tx = event_tx.clone(); + let tracked = tracked.clone(); + let supervisor_inner = supervisor.clone(); + let mut retry_shutdown_rx = supervisor.subscribe(); + let retry_handle = tokio::spawn(async move { + for attempt in 0..5 { + if attempt > 0 { + tokio::select! { + _ = tokio::time::sleep( + std::time::Duration::from_secs(1) + ) => {} + _ = retry_shutdown_rx.recv() => return, + } + } + if try_attach_device( + &path, &hotkey_rx, &event_tx, &tracked, &supervisor_inner, + ).await { + break; + } + } + }); + supervisor.register("hotplug-retry", retry_handle).await; + } + _ = shutdown_rx.recv() => break, + } } } @@ -193,7 +302,8 @@ pub fn check_access() -> Result<(), String> { async fn scan_and_attach( hotkey_rx: &watch::Receiver>, event_tx: &mpsc::Sender, - tracked: &Arc>>, + tracked: &TrackedDevices, + supervisor: &SupervisorHandle, ) { let input_dir = Path::new("/dev/input"); let entries = match std::fs::read_dir(input_dir) { @@ -207,21 +317,31 @@ async fn scan_and_attach( for entry in entries.flatten() { let path = entry.path(); if is_event_device(&path) { - try_attach_device(&path, hotkey_rx, event_tx, tracked).await; + try_attach_device(&path, hotkey_rx, event_tx, tracked, supervisor).await; } } } /// Try to open a device and start listening if it supports the target key. /// Returns true if the device was successfully attached. +/// +/// Insert-into-tracked-then-spawn-then-release-mutex makes attachment +/// atomic against concurrent hotplug + scan; the previous design's +/// remove-after-task-exits window allowed double-attaches. async fn try_attach_device( path: &Path, hotkey_rx: &watch::Receiver>, event_tx: &mpsc::Sender, - tracked: &Arc>>, + tracked: &TrackedDevices, + supervisor: &SupervisorHandle, ) -> bool { - let mut tracked_set = tracked.lock().await; - if tracked_set.contains(path) { + // Hold the mutex across the contains-check, the insert, AND the + // spawn registration. This is the TOCTOU fix for Race-extra: the + // previous implementation released the mutex before spawning and + // before removal, leaving windows where concurrent scan + hotplug + // could double-attach the same device. + let mut tracked_map = tracked.lock().await; + if tracked_map.contains_key(path) { return true; } @@ -249,27 +369,52 @@ async fn try_attach_device( "attached hotkey listener" ); - tracked_set.insert(path.to_path_buf()); - drop(tracked_set); + // Insert BEFORE spawning the listener task so a racing caller (the + // scanner running concurrently with a hotplug retry, for example) + // sees the entry and short-circuits. + tracked_map.insert(path.to_path_buf(), ()); - // Spawn a listener task for this device - let hotkey_rx = hotkey_rx.clone(); - let event_tx = event_tx.clone(); + // Clone everything the spawned task needs before we release the + // mutex so the release point is a single statement. + let hotkey_rx_owned = hotkey_rx.clone(); + let event_tx_owned = event_tx.clone(); let path_owned = path.to_path_buf(); - let tracked = tracked.clone(); + let tracked_for_cleanup = tracked.clone(); + let mut shutdown_rx = supervisor.subscribe(); - tokio::spawn(async move { - if let Err(e) = device_listener(device, hotkey_rx, event_tx).await { - tracing::warn!( - path = %path_owned.display(), - error = %e, - "device listener ended" - ); + let listener_handle = tokio::spawn(async move { + let listener_fut = device_listener(device, hotkey_rx_owned, event_tx_owned); + tokio::select! { + res = listener_fut => { + if let Err(e) = res { + tracing::warn!( + path = %path_owned.display(), + error = %e, + "device listener ended" + ); + } + } + _ = shutdown_rx.recv() => { + tracing::debug!( + target: "lumotia_hotkey", + path = %path_owned.display(), + "device listener received shutdown signal" + ); + } } - // Remove from tracked set so hotplug can re-attach if reconnected - tracked.lock().await.remove(&path_owned); + // Remove from tracked set so hotplug can re-attach if reconnected. + tracked_for_cleanup.lock().await.remove(&path_owned); }); + drop(tracked_map); + + // Register with the supervisor. This await is brief — it just locks + // the supervisor inner Vec and pushes — and happens outside the + // tracked-map lock. + supervisor + .register("device-listener", listener_handle) + .await; + true } @@ -427,4 +572,11 @@ mod tests { keys.insert(Key::KEY_R); assert!(!device_supports_combo(Some(&keys), &combo_for(KEY_D))); } + + // TODO(test): Race-extra (TOCTOU on `tracked`) is hard to exercise + // without real /dev/input/event* devices + the udev attach race. + // The new insert-before-spawn + supervisor-owned-handle design + // closes the window by construction; a deterministic test would need + // to fake the evdev::Device::open path which the crate doesn't + // currently expose. See atomiser finding "Race-extra" for context. } diff --git a/crates/hotkey/src/stub.rs b/crates/hotkey/src/stub.rs index 4ab1361..97a6fd7 100644 --- a/crates/hotkey/src/stub.rs +++ b/crates/hotkey/src/stub.rs @@ -2,6 +2,10 @@ //! //! On macOS and Windows, Tauri's global-shortcut plugin handles hotkeys //! natively. This stub exists so the crate compiles on all platforms. +//! +//! The signature here mirrors the Linux backend so the consumer (the +//! Tauri command layer) can use the same call shape on every platform: +//! `EvdevHotkeyListener::start(...).await` and `listener.stop().await`. use tokio::sync::mpsc; @@ -18,12 +22,16 @@ pub enum HotkeyEvent { pub struct EvdevHotkeyListener; impl EvdevHotkeyListener { - pub fn start(_combo: HotkeyCombo, _event_tx: mpsc::Sender) -> Self { + /// Mirrors the Linux backend's async constructor so consumers can + /// `await` the same call shape regardless of platform. + pub async fn start(_combo: HotkeyCombo, _event_tx: mpsc::Sender) -> Self { tracing::info!("evdev hotkey listener is a no-op on this platform"); Self } pub fn set_hotkey(&self, _combo: HotkeyCombo) {} - pub async fn stop(&self) {} + /// Consuming stop mirrors the Linux backend so callers can rely on + /// the listener being unusable after shutdown on every platform. + pub async fn stop(self) {} } diff --git a/crates/hotkey/src/supervisor.rs b/crates/hotkey/src/supervisor.rs new file mode 100644 index 0000000..b8a5c74 --- /dev/null +++ b/crates/hotkey/src/supervisor.rs @@ -0,0 +1,238 @@ +//! Task supervisor for the evdev hotkey listener. +//! +//! Owns `JoinHandle`s for every task the listener spawns (scanner, hotplug +//! watcher, hotplug retry tasks, per-device listeners). Provides a +//! broadcast shutdown channel that every cooperating task subscribes to. +//! +//! Three concurrency leaks this fixes: +//! - **Race-1**: per-device listener tasks had no cancellation path, so +//! after `stop()` returned they kept running and emitting events. +//! - **Race-2**: the forwarder task in `commands::hotkey` had no +//! `JoinHandle` tracking, so a reconfigure leaked a permanent forwarder +//! that received duplicated events. +//! - **Race-extra (TOCTOU)**: the `tracked` `HashSet` could miss-attach or +//! double-attach because removal happened after the listener task +//! exited, leaving a window where a concurrent hotplug saw stale state. +//! The new `HashMap` keyed by canonical path, coupled with +//! insert-before-spawn under one mutex hold, makes attachment atomic. +//! +//! See `tests/listener_lifecycle.rs` for regression coverage. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::{broadcast, Mutex}; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +/// How long to wait for any single task to drain on `shutdown()` before +/// we give up on it. Two seconds is generous for cooperative shutdown +/// via the broadcast channel — anything slower is treated as a stuck +/// task and detached (NOT aborted: `timeout(d, handle).await` consumes +/// the `JoinHandle` by value and dropping a `JoinHandle` detaches the +/// task, so the task keeps running until the tokio runtime tears it +/// down). A warning is logged so the operator can investigate. +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); + +/// Capacity of the broadcast shutdown channel. Eight is far more than we +/// expect to ever need (one slot per concurrent subscriber, drained +/// immediately), but cheap enough that a tighter bound buys us nothing. +const SHUTDOWN_CHANNEL_CAPACITY: usize = 8; + +/// Inner state of the supervisor, behind a mutex so concurrent spawn +/// sites can register handles. +struct SupervisorInner { + handles: Vec<(&'static str, JoinHandle<()>)>, +} + +/// Shareable handle to a task supervisor. Hand a clone of this to every +/// task that needs to register child tasks (e.g. the hotplug watcher +/// needs to register retry tasks it spawns). The actual shutdown is +/// driven by [`SupervisorHandle::shutdown`], which is called once by the +/// listener's `stop()`. +#[derive(Clone)] +pub(crate) struct SupervisorHandle { + shutdown_tx: broadcast::Sender<()>, + inner: Arc>, +} + +impl SupervisorHandle { + pub(crate) fn new() -> Self { + let (shutdown_tx, _) = broadcast::channel(SHUTDOWN_CHANNEL_CAPACITY); + Self { + shutdown_tx, + inner: Arc::new(Mutex::new(SupervisorInner { + handles: Vec::new(), + })), + } + } + + /// Subscribe to the shutdown signal. Tasks should `tokio::select!` + /// this receiver alongside their work so they exit promptly on + /// `shutdown()`. + pub(crate) fn subscribe(&self) -> broadcast::Receiver<()> { + self.shutdown_tx.subscribe() + } + + /// Register a spawned task. The `label` is logged only when the task + /// has to be force-aborted, so concise tags like `"scanner"` are + /// sufficient. + pub(crate) async fn register(&self, label: &'static str, handle: JoinHandle<()>) { + self.inner.lock().await.handles.push((label, handle)); + } + + /// Current number of registered tasks. Useful for logging. + pub(crate) async fn task_count(&self) -> usize { + self.inner.lock().await.handles.len() + } + + /// Fire the shutdown signal WITHOUT awaiting any tasks. Used by + /// `Drop` for paranoia — async drop is not available in stable Rust, + /// so we cannot join handles here. + pub(crate) fn signal_shutdown_nonblocking(&self) { + let _ = self.shutdown_tx.send(()); + } + + /// Signal every subscriber to shut down, then await each registered + /// task with a per-task timeout. Any task that doesn't drain inside + /// the timeout is detached and logged via `tracing::warn!`. + pub(crate) async fn shutdown(&self) { + // `send` only errors when there are no live receivers, which is a + // perfectly fine state — every subscriber already exited or none + // was ever attached. + let _ = self.shutdown_tx.send(()); + + // Drain handles. We hold the lock only long enough to swap the + // Vec out so tasks racing to register late don't block our wait. + let handles: Vec<(&'static str, JoinHandle<()>)> = { + let mut guard = self.inner.lock().await; + std::mem::take(&mut guard.handles) + }; + + let task_count = handles.len(); + + for (label, handle) in handles { + match timeout(SHUTDOWN_TIMEOUT, handle).await { + Ok(Ok(())) => { + // Clean exit. + } + Ok(Err(join_err)) => { + tracing::warn!( + target: "lumotia_hotkey", + task = label, + error = %join_err, + "supervised task ended abnormally" + ); + } + Err(_elapsed) => { + // Timed out — task is stuck. We can't await again + // after the timeout future consumed the handle, so + // log and detach. Every task in this crate selects + // on the shutdown broadcast and sees senders drop, + // so reaching this branch indicates a genuine bug. + tracing::warn!( + target: "lumotia_hotkey", + task = label, + timeout_secs = SHUTDOWN_TIMEOUT.as_secs(), + "supervised task did not drain within timeout; detaching" + ); + } + } + } + + // Drain anything registered while we were awaiting (a late + // hotplug retry, for instance). These tasks have already seen + // the broadcast and should be on their way out. + let late: Vec<(&'static str, JoinHandle<()>)> = { + let mut guard = self.inner.lock().await; + std::mem::take(&mut guard.handles) + }; + let late_count = late.len(); + for (label, handle) in late { + match timeout(SHUTDOWN_TIMEOUT, handle).await { + Ok(Ok(())) => {} + Ok(Err(e)) => { + tracing::warn!( + target: "lumotia_hotkey", + task = label, + error = %e, + "late-registered task ended abnormally" + ); + } + Err(_) => { + tracing::warn!( + target: "lumotia_hotkey", + task = label, + "late-registered task did not drain" + ); + } + } + } + + tracing::info!( + target: "lumotia_hotkey", + task_count = task_count + late_count, + "supervisor stopped" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn shutdown_joins_all_registered_tasks() { + let sup = SupervisorHandle::new(); + let counter = Arc::new(AtomicUsize::new(0)); + + for i in 0..5 { + let mut rx = sup.subscribe(); + let counter = counter.clone(); + let handle = tokio::spawn(async move { + let _ = rx.recv().await; + counter.fetch_add(1, Ordering::SeqCst); + tracing::debug!(task_id = i, "task exiting"); + }); + sup.register("test-task", handle).await; + } + + assert_eq!(sup.task_count().await, 5); + + sup.shutdown().await; + + assert_eq!( + counter.load(Ordering::SeqCst), + 5, + "every registered task should observe shutdown and run its exit path" + ); + } + + /// A stuck task — one that does not subscribe to broadcast shutdown + /// and would otherwise run forever — must not block `shutdown()` + /// past the per-task timeout. Note: the supervisor does NOT abort + /// the task; it detaches it. Verifying detach behaviour directly is + /// not possible from this test because `register()` moves the + /// `JoinHandle` into the supervisor's inner Vec. The bounded-elapsed + /// assertion below is what guards against a regression that + /// reintroduces an unbounded `handle.await` in `shutdown()`. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn shutdown_does_not_block_on_stuck_tasks_after_timeout() { + let sup = SupervisorHandle::new(); + let handle = tokio::spawn(async move { + // Sleep forever — does NOT subscribe to shutdown. + tokio::time::sleep(Duration::from_secs(3600)).await; + }); + sup.register("stuck", handle).await; + + let start = std::time::Instant::now(); + sup.shutdown().await; + let elapsed = start.elapsed(); + + assert!( + elapsed < Duration::from_secs(4), + "shutdown should not block past timeout * 2, took {elapsed:?}" + ); + } +} diff --git a/crates/hotkey/tests/listener_lifecycle.rs b/crates/hotkey/tests/listener_lifecycle.rs new file mode 100644 index 0000000..4d63500 --- /dev/null +++ b/crates/hotkey/tests/listener_lifecycle.rs @@ -0,0 +1,194 @@ +//! Regression tests for the three concurrency leaks the code-atomiser +//! flagged in the hotkey listener: +//! +//! - **Race-1** — orphaned per-device listener tasks after `stop()`. +//! Coverage: `listener_stop_drops_internal_senders` — exercises the +//! only side-effect we can observe through the public API. After +//! `stop()` every internal device-listener task must drop its +//! `mpsc::Sender` clone, which we detect by asserting +//! that the receiving end's `recv()` returns `None`. +//! +//! - **Race-2** — leaked forwarder task on reconfigure. Coverage: +//! `reconfigure_does_not_leak_forwarder` — simulates the exact pattern +//! the Tauri command layer uses (listener + forwarder pair), confirms +//! that after a reconfigure the OLD forwarder has joined and only the +//! new one is consuming events. +//! +//! - **Race-extra (TOCTOU)** — see `// TODO(test):` in +//! `crates/hotkey/src/linux.rs`. Exercising the TOCTOU window +//! deterministically requires faking the evdev::Device::open path, +//! which the crate does not currently expose. The fix is closed by +//! construction (insert-before-spawn under one mutex hold + supervisor +//! ownership of every spawn handle). + +#![cfg(target_os = "linux")] + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use lumotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent}; +use tokio::sync::mpsc; + +fn dummy_combo() -> HotkeyCombo { + // KEY_R = 19, the default Lumotia hotkey. No device on the CI box is + // expected to match for actual key events — these tests exercise the + // lifecycle, not the event-firing path. + HotkeyCombo { + ctrl: true, + shift: true, + alt: false, + super_key: false, + key_code: 19, + label: "Ctrl+Shift+R".to_string(), + } +} + +/// Race-1 regression. After `stop()`, every device-listener and watcher +/// task spawned by the listener must exit and drop its `event_tx` clone. +/// We detect that by holding the receiving end and observing the +/// channel-closed signal (`recv()` returning `None`). +/// +/// If the bug regressed (listeners orphaned), `recv()` would block +/// indefinitely because the leaked tasks still hold sender clones, and +/// the test would time out. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn listener_stop_drops_internal_senders() { + let (event_tx, mut event_rx) = mpsc::channel::(64); + + let listener = EvdevHotkeyListener::start(dummy_combo(), event_tx).await; + + // Let the supervisor spin up its tasks. The scanner+hotplug watcher + // tasks subscribe to broadcast shutdown during construction, so the + // scheduling order doesn't really matter, but we yield once for + // robustness. + tokio::task::yield_now().await; + + let stop_start = Instant::now(); + listener.stop().await; + let stop_elapsed = stop_start.elapsed(); + + // Stop must itself be bounded. The supervisor's per-task timeout is + // 2 s, and we have at most a handful of internal tasks (scanner, + // hotplug, plus however many real /dev/input devices the test + // sandbox exposes — usually zero on CI). Cap total stop time at + // 10 s to give us a clear failure rather than a hung CI runner. + assert!( + stop_elapsed < Duration::from_secs(10), + "EvdevHotkeyListener::stop() should bound on supervisor timeout; took {stop_elapsed:?}" + ); + + // After stop, every internal sender clone must be dropped, which + // closes the channel for the receiver. `recv()` returns None on a + // closed-and-drained channel. We wrap in a timeout so a regressed + // implementation (leaked listener tasks holding sender clones) + // surfaces as a clean assertion failure rather than a hung test. + let recv_result = tokio::time::timeout(Duration::from_secs(5), event_rx.recv()).await; + + match recv_result { + Ok(None) => { + // Pass — channel closed, no sender clones leaked. + } + Ok(Some(ev)) => panic!( + "received hotkey event {ev:?} after stop() — listener tasks should have exited \ + and dropped their senders, indicating Race-1 regressed" + ), + Err(_) => panic!( + "timed out waiting for event_rx to close after stop() — internal sender clones \ + were not dropped, indicating Race-1 regressed (orphaned listener tasks)" + ), + } +} + +/// Race-2 regression. Simulates the Tauri command layer's +/// listener+forwarder pair. After a reconfigure (stop old, start new), +/// only the NEW forwarder must be alive — the previous implementation +/// leaked one forwarder per reconfigure. +/// +/// We assert leak-freedom by: +/// 1. Holding a JoinHandle to each forwarder we spawn. +/// 2. After the reconfigure, asserting the old forwarder's JoinHandle +/// is finished within a bounded timeout. +/// +/// The new forwarder's JoinHandle must NOT be finished (it's still +/// receiving from the new listener). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reconfigure_does_not_leak_forwarder() { + let received_first = Arc::new(AtomicUsize::new(0)); + let received_second = Arc::new(AtomicUsize::new(0)); + + // ---- First listener + forwarder ---- + let (event_tx_1, mut event_rx_1) = mpsc::channel::(64); + let listener_1 = EvdevHotkeyListener::start(dummy_combo(), event_tx_1).await; + let counter_1 = received_first.clone(); + let forwarder_1 = tokio::spawn(async move { + while let Some(_event) = event_rx_1.recv().await { + counter_1.fetch_add(1, Ordering::SeqCst); + } + // Returns when the channel closes (all senders dropped). That's + // the only clean way for the forwarder to exit, and it must + // happen on reconfigure for the leak to be fixed. + }); + + tokio::task::yield_now().await; + + // ---- Reconfigure: stop old, start new ---- + // This mirrors `start_evdev_hotkey` in src-tauri/src/commands/hotkey.rs + // after the fix: stop old listener (which drains every internal task + // via the supervisor) THEN join the old forwarder (which exits when + // all sender clones drop) BEFORE installing the new pair. + listener_1.stop().await; + + // Old forwarder must finish in bounded time. If Race-2 regressed + // (orphaned listener tasks still holding sender clones), the + // forwarder would never see `None` from recv() and this timeout + // would fire. + let join_result = tokio::time::timeout(Duration::from_secs(5), forwarder_1).await; + assert!( + join_result.is_ok(), + "old forwarder did not join after listener.stop() — Race-2 regressed: \ + orphaned listener tasks are still holding event_tx clones" + ); + // Verify the inner result (forwarder didn't panic). + join_result.unwrap().expect("old forwarder panicked"); + + // ---- Second listener + forwarder ---- + let (event_tx_2, mut event_rx_2) = mpsc::channel::(64); + let listener_2 = EvdevHotkeyListener::start(dummy_combo(), event_tx_2).await; + let counter_2 = received_second.clone(); + let forwarder_2 = tokio::spawn(async move { + while let Some(_event) = event_rx_2.recv().await { + counter_2.fetch_add(1, Ordering::SeqCst); + } + }); + + tokio::task::yield_now().await; + + // Sanity check: the new forwarder is still running (not yet + // joined). `is_finished()` returns true only when the task has + // completed. + assert!( + !forwarder_2.is_finished(), + "new forwarder must still be running after reconfigure — otherwise \ + the new listener's senders were dropped prematurely" + ); + + // ---- Cleanup ---- + listener_2.stop().await; + let cleanup_join = tokio::time::timeout(Duration::from_secs(5), forwarder_2).await; + assert!( + cleanup_join.is_ok(), + "second forwarder also failed to drain after stop()" + ); + + // We don't actually assert on the counters — these tests run without + // a matching evdev device, so no Pressed/Released events fire. The + // leak detection is in the JoinHandle behaviour above, not the event + // count. The counters exist so the test compiles as a real + // forwarder pattern matching what commands::hotkey does in + // production. + let _ = ( + received_first.load(Ordering::SeqCst), + received_second.load(Ordering::SeqCst), + ); +} diff --git a/crates/llm/Cargo.toml b/crates/llm/Cargo.toml index b3215bf..452352c 100644 --- a/crates/llm/Cargo.toml +++ b/crates/llm/Cargo.toml @@ -1,13 +1,15 @@ [package] -name = "magnotia-llm" -version = "0.1.0" -edition = "2021" -description = "Local LLM engine for Magnotia (Qwen3.5 / Qwen3.6 via llama-cpp-2): transcript cleanup, task extraction, micro-step decomposition" +name = "lumotia-llm" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +description = "Local LLM engine for Lumotia (Qwen3.5 / Qwen3.6 via llama-cpp-2): transcript cleanup, task extraction, micro-step decomposition" [features] # Default desktop build keeps the existing openmp + vulkan acceleration. # Mobile / CPU-only targets can drop one or both via: -# cargo build -p magnotia-llm --no-default-features +# cargo build -p lumotia-llm --no-default-features # These are independent so an Android Vulkan build can opt into vulkan # without openmp (the NDK ships OpenMP libs but the toolchain configuration # is fragile across NDK versions). @@ -16,10 +18,10 @@ gpu-vulkan = ["llama-cpp-2/vulkan"] openmp = ["llama-cpp-2/openmp"] [dependencies] -magnotia-core = { path = "../core" } +lumotia-core = { path = "../core" } encoding_rs = "0.8" futures-util = "0.3" -llama-cpp-2 = { version = "0.1.144", default-features = false } +llama-cpp-2 = { version = "0.1.146", default-features = false } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/llm/src/lib.rs b/crates/llm/src/lib.rs index 1453ab9..5fddcfb 100644 --- a/crates/llm/src/lib.rs +++ b/crates/llm/src/lib.rs @@ -1,5 +1,6 @@ use std::num::NonZeroU32; use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use encoding_rs::UTF_8; @@ -9,7 +10,7 @@ use llama_cpp_2::llama_batch::LlamaBatch; use llama_cpp_2::model::params::LlamaModelParams; use llama_cpp_2::model::{AddBos, LlamaChatMessage, LlamaChatTemplate, LlamaModel}; use llama_cpp_2::sampling::LlamaSampler; -use magnotia_core::tuning::{inference_thread_count, Workload}; +use lumotia_core::tuning::{inference_thread_count, Workload}; use serde::{Deserialize, Serialize}; pub mod grammars; @@ -25,12 +26,31 @@ const MAX_CONTEXT_TOKENS: u32 = 8192; const CONTEXT_RESERVE_TOKENS: u32 = 64; const GENERATION_SEED: u32 = 0; +/// Maximum number of tasks returned by the rule-based fallback extractor. +/// Caps output to avoid wall-of-text dumps when the transcript is dense. +const MAX_RULE_BASED_TASKS: usize = 10; + +/// Indicates which extraction path produced the task list. +/// Propagated to callers so the UI can label rule-based results accordingly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskExtractionSource { + /// Tasks extracted by the local LLM. + Llm, + /// LLM path failed; tasks extracted by the rule-based regex fallback. + RuleBased, +} + #[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("LLM not loaded. Download an AI model in Settings.")] NotLoaded, #[error("LLM load failed: {0}")] LoadFailed(String), + #[error( + "Another LLM load is already in flight; refusing to start a parallel load \ + or modify engine state mid-load." + )] + AlreadyLoading, #[error( "prompt too long: {prompt_tokens} prompt tokens exceed the {available_prompt_tokens}-token prompt budget for an {context_window}-token context with {max_tokens} reserved response tokens" )] @@ -83,6 +103,29 @@ struct LlmState { #[derive(Clone, Default)] pub struct LlmEngine { inner: Arc>, + /// Flag held for the duration of a model load. The std::sync::Mutex + /// covers cheap state mutations (~microseconds); the multi-second + /// `LlamaModel::load_from_file` call now runs *outside* the mutex, + /// so polls like `is_loaded()` / `loaded_model_id()` (called from + /// sync Tauri handlers without `spawn_blocking`) don't park tokio + /// worker threads on a slow C++ FFI call. This Atomic also doubles + /// as a TOCTOU guard: two concurrent `load_model` invocations on + /// the same engine will not both reach the heavy load — the second + /// returns `EngineError::AlreadyLoading`. + loading: Arc, +} + +/// RAII guard that clears the `loading` flag on drop, including on +/// panic / early-return. Prevents the engine getting stuck in a +/// "permanently loading" state if a load fails midway. +struct LoadingGuard { + flag: Arc, +} + +impl Drop for LoadingGuard { + fn drop(&mut self) { + self.flag.store(false, Ordering::Release); + } } impl LlmEngine { @@ -94,24 +137,103 @@ impl LlmEngine { self.load_model(LlmModelId::default_tier(), model_path, true) } + // instrument: the load is multi-second (`LlamaBackend::init` + + // mmap + GPU layer init). Tagging events with `model_id` and + // `use_gpu` lets the operator separate the GPU sequential-guard + // logs and llama-backend init lines from the LLM transcription + // pipeline by structured field rather than by adjacency. + #[tracing::instrument(skip_all, fields(model_id = %model_id.as_str(), use_gpu = use_gpu))] pub fn load_model( &self, model_id: LlmModelId, model_path: &Path, use_gpu: bool, ) -> Result<(), EngineError> { - let mut guard = self.inner.lock().unwrap(); + self.load_model_with(model_id, model_path, use_gpu, |backend, path, params| { + LlamaModel::load_from_file(backend, path, params) + .map_err(|e| EngineError::LoadFailed(format!("model load: {e}"))) + }) + } - if let Some(loaded) = &guard.loaded { - if loaded.model_id == model_id.as_str() - && loaded.model_path == model_path.display().to_string() - && loaded.use_gpu == use_gpu - { - return Ok(()); + /// Core load implementation with a swappable file-loader closure. + /// Production callers use `load_model`, which delegates here with + /// the real `LlamaModel::load_from_file`. Tests inject a sleepy / + /// counting closure to exercise the locking discipline without + /// pulling a real GGUF off disk. + /// + /// Locking discipline (the whole point of this function): + /// 1. Take the mutex briefly to compare against the currently + /// loaded triple — if it matches, return early. No-op fast path. + /// 2. CAS the `loading` flag from false → true. If another load is + /// already in flight, refuse with `AlreadyLoading` rather than + /// starting a parallel one. A `LoadingGuard` ensures the flag + /// is cleared on every exit path including panic. + /// 3. Take the mutex briefly to drop the OLD model Arc (frees its + /// VRAM via `llama_free_model`) before the new load begins. + /// The backend Arc is preserved — `LlamaBackend::init()` is a + /// one-shot per process (an `AtomicBool` in llama-cpp-2 enforces + /// `BackendAlreadyInitialized` on a second call), so we must + /// never drop the backend while the process keeps running. + /// Note: `is_loaded()` reports false during the swap window — + /// that is the correct semantics. Callers wanting "model X is + /// loaded" must check `loaded_model_id()` against their target. + /// 4. Initialise the backend if absent (first-ever load only) and + /// run the slow `load_from_file` call — both OUTSIDE the mutex. + /// 5. Take the mutex briefly to install the new backend (if just + /// initialised) and the new model Arc. + fn load_model_with( + &self, + model_id: LlmModelId, + model_path: &Path, + use_gpu: bool, + loader: F, + ) -> Result<(), EngineError> + where + F: FnOnce(&LlamaBackend, &Path, &LlamaModelParams) -> Result, + { + // Step 1: short crit section — already-loaded fast path. + { + let guard = self.inner.lock().unwrap(); + if let Some(loaded) = &guard.loaded { + if loaded.model_id == model_id.as_str() + && loaded.model_path == model_path.display().to_string() + && loaded.use_gpu == use_gpu + { + return Ok(()); + } } } - let backend = match guard.backend.clone() { + // Step 2: claim the loading slot. Refuse if a parallel load is + // already mid-flight rather than starting a second slow load + // and silently overwriting the first. + if self + .loading + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(EngineError::AlreadyLoading); + } + let _loading_guard = LoadingGuard { + flag: Arc::clone(&self.loading), + }; + + // Step 3: short crit section — drop the OLD model so its VRAM is + // released BEFORE we allocate the new one. Without this, a swap + // briefly holds two models resident (Lifecycle-1: an + // ~17 GB Q4 27B swap on a 24 GB card OOMs even though either + // model fits alone). Keep the backend Arc — see locking notes. + let existing_backend = { + let mut guard = self.inner.lock().unwrap(); + guard.model = None; + guard.loaded = None; + guard.backend.clone() + }; + + // Step 4: heavy work OUTSIDE the mutex. `is_loaded()` and + // `loaded_model_id()` can be polled freely here without parking + // tokio worker threads. + let backend = match existing_backend { Some(existing) => existing, None => Arc::new( LlamaBackend::init() @@ -121,27 +243,84 @@ impl LlmEngine { let gpu_layers = if use_gpu { u32::MAX } else { 0 }; let params = LlamaModelParams::default().with_n_gpu_layers(gpu_layers); - let model = LlamaModel::load_from_file(&backend, model_path, ¶ms) - .map_err(|e| EngineError::LoadFailed(format!("model load: {e}")))?; + let model = loader(&backend, model_path, ¶ms)?; - guard.backend = Some(backend); - guard.model = Some(Arc::new(model)); - guard.loaded = Some(LoadedModelState { - model_id: model_id.as_str().to_string(), - model_path: model_path.display().to_string(), - use_gpu, - }); + // Step 5: short crit section — install the new state. + { + let mut guard = self.inner.lock().unwrap(); + guard.backend = Some(backend); + guard.model = Some(Arc::new(model)); + guard.loaded = Some(LoadedModelState { + model_id: model_id.as_str().to_string(), + model_path: model_path.display().to_string(), + use_gpu, + }); + } + // `_loading_guard` drops here and clears the flag. Ok(()) } pub fn unload(&self) -> Result<(), EngineError> { + // Refuse to unload mid-load. Without this check, `load_model_with` + // is mid-flight (it has cleared `model` / `loaded` in step 3 and + // is about to install new state in step 5); a concurrent unload + // would do nothing (the state is already None), return Ok, and + // then the load's step 5 silently overwrites — the caller saw + // unload success but the engine ends up loaded. Phase B.7 audit + // residual (2026-05-14): the load-vs-load TOCTOU was closed by + // `AlreadyLoading` in cde985d but the unload-vs-load race was + // left open. Same flag covers both directions. + if self.is_loading() { + return Err(EngineError::AlreadyLoading); + } let mut guard = self.inner.lock().unwrap(); guard.model = None; - guard.backend = None; + // Backend is process-singleton (llama-cpp-2 enforces this via + // `LLAMA_BACKEND_INITIALIZED`). Dropping the Arc here would call + // `llama_backend_free` and a subsequent `init` would succeed, but + // we keep it resident to avoid the init/free churn on every + // load/unload cycle. guard.loaded = None; Ok(()) } + /// True iff a model load is currently in flight. Exposed for tests + /// and frontends that want to render a "loading…" state without + /// polling `is_loaded()` (which returns false during a swap). + pub fn is_loading(&self) -> bool { + self.loading.load(Ordering::Acquire) + } + + /// Test-only harness: runs `op` while holding the same locking + /// discipline as `load_model_with` (loading flag claimed, model + /// state cleared, slow op runs OUTSIDE the inner mutex, new state + /// installed at the end). Used by the regression test to verify + /// that `is_loaded()` / `loaded_model_id()` don't block on the + /// slow section. Not part of the public API. + #[cfg(test)] + pub(crate) fn __test_run_with_lock_discipline(&self, op: F) -> Result<(), EngineError> + where + F: FnOnce(), + { + if self + .loading + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(EngineError::AlreadyLoading); + } + let _loading_guard = LoadingGuard { + flag: Arc::clone(&self.loading), + }; + { + let mut guard = self.inner.lock().unwrap(); + guard.model = None; + guard.loaded = None; + } + op(); + Ok(()) + } + pub fn is_loaded(&self) -> bool { self.inner.lock().unwrap().model.is_some() } @@ -212,6 +391,10 @@ impl LlmEngine { generated.push_str(&piece); sampler.accept(next); + if config.grammar.is_none() && json_envelope_complete(&generated) { + break; + } + if let Some(stop_index) = first_stop_index(&generated, &config.stop_sequences) { generated.truncate(stop_index); break; @@ -296,13 +479,12 @@ impl LlmEngine { } /// Phase 9 content-tag extraction. Emits a single (topic, intent) - /// pair under the `CONTENT_TAGS_GRAMMAR` GBNF. Truncates to the - /// trailing 2000 chars of the transcript so the prompt budget - /// stays well under any model's context window. Determinism is - /// enforced by temperature 0.0 and the closed-set intent grammar - /// rule; on the rare case the model emits a parse-able-but-out-of- - /// set intent, we re-validate with `is_valid_intent` and bubble - /// `InvalidJson` so the frontend toasts a clear error. + /// pair as JSON. Truncates to the trailing 2000 chars of the + /// transcript so the prompt budget stays well under any model's + /// context window. Determinism is enforced by temperature 0.0; + /// the parsed intent is re-validated with `is_valid_intent` and + /// invalid JSON bubbles as `InvalidJson` so the frontend toasts a + /// clear error. pub fn extract_content_tags( &self, transcript: &str, @@ -338,12 +520,11 @@ impl LlmEngine { max_tokens: 96, temperature: 0.0, stop_sequences: vec!["<|im_end|>".to_string(), "<|im_end_of_text|>".to_string()], - grammar: Some(grammars::CONTENT_TAGS_GRAMMAR.to_string()), + grammar: None, }, )?; - let tags: prompts::ContentTags = serde_json::from_str(raw.trim()) - .map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))?; + let tags: prompts::ContentTags = parse_json_payload(&raw)?; if !prompts::is_valid_intent(&tags.intent) { return Err(EngineError::InvalidJson(format!( "intent out of closed set: {}", @@ -386,6 +567,31 @@ impl LlmEngine { parse_string_array(&raw) } + /// Wrapper around [`extract_tasks_with_feedback`] that NEVER returns + /// an error: if the LLM path fails for any reason the rule-based + /// extractor fires as a safety net, satisfying the data-loss contract + /// documented in `docs/release/v0.1-known-limitations.md`. + /// + /// Returns `(tasks, source)` where `source` tells the caller which + /// path produced the results so the UI can label them. + pub fn extract_tasks_with_fallback( + &self, + transcript: &str, + examples: &[prompts::FeedbackExample], + ) -> (Vec, TaskExtractionSource) { + match self.extract_tasks_with_feedback(transcript, examples) { + Ok(tasks) => (tasks, TaskExtractionSource::Llm), + Err(err) => { + tracing::warn!( + "LLM task extraction failed; using rule-based fallback: {}", + err + ); + let tasks = rule_based_extract_tasks(transcript); + (tasks, TaskExtractionSource::RuleBased) + } + } + } + fn loaded_handles(&self) -> Result<(Arc, Arc), EngineError> { let guard = self.inner.lock().unwrap(); let backend = guard.backend.clone().ok_or(EngineError::NotLoaded)?; @@ -459,6 +665,79 @@ fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option { .min() } +fn json_envelope_complete(text: &str) -> bool { + extract_json_envelope(text) == Some(text.trim()) +} + +fn extract_json_envelope(text: &str) -> Option<&str> { + // Phase B.9 audit residual (2026-05-14): strip the leading + // `` reasoning block before scanning. Qwen-style + // models emit non-empty reasoning when thinking mode is on, and + // the reasoning can contain JSON-looking literals (e.g. + // "the answer should be {\"x\":1}") or unbalanced braces ("I wonder + // about {..."). The naive "find the first '{' or '['" extractor + // would then either return the wrong envelope or pollute the + // brace-stack and return None. We split on the FIRST `` — + // anything before it is reasoning, anything after is the answer + // proper. Falls back to the whole text when no `` is + // present (covers non-reasoning models and the empty-thinking + // case already covered by `extract_json_envelope_skips_qwen_thinking_prefix`). + let scan_region = text + .split_once("") + .map(|(_, rest)| rest) + .unwrap_or(text); + + let start = scan_region + .char_indices() + .find_map(|(idx, ch)| (ch == '{' || ch == '[').then_some(idx))?; + let mut chars = scan_region[start..].char_indices(); + let (_, first) = chars.next()?; + + let mut stack = vec![match first { + '{' => '}', + '[' => ']', + _ => unreachable!(), + }]; + let mut in_string = false; + let mut escaped = false; + + for (offset, ch) in chars { + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + match ch { + '"' => in_string = true, + '{' => stack.push('}'), + '[' => stack.push(']'), + '}' | ']' => { + if stack.pop() != Some(ch) { + return None; + } + if stack.is_empty() { + let end = start + offset + ch.len_utf8(); + return Some(&scan_region[start..end]); + } + } + _ => {} + } + } + + None +} + +fn parse_json_payload Deserialize<'de>>(raw: &str) -> Result { + let payload = extract_json_envelope(raw).unwrap_or(raw.trim()); + serde_json::from_str(payload).map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}"))) +} + fn render_chat_prompt( model: &LlamaModel, messages: &[(&str, &str)], @@ -501,6 +780,129 @@ fn parse_string_array(raw: &str) -> Result, EngineError> { Ok(normalized) } +/// Rule-based task extractor used as the safety net when the LLM extraction +/// path fails. Per `docs/release/v0.1-known-limitations.md`, task extraction +/// must NEVER return zero tasks just because the LLM failed. +/// +/// Heuristic: split on sentence boundaries (`. ? ! \n`), keep sentences that +/// begin with (or contain near the start) an imperative-style cue. Trim, +/// dedupe, cap at [`MAX_RULE_BASED_TASKS`] to avoid wall-of-text dumps. +pub fn rule_based_extract_tasks(transcript: &str) -> Vec { + // Filler words that may precede the real imperative start. + const FILLER: &[&str] = &["and ", "so ", "then ", "also ", "well ", "okay ", "ok "]; + + // Phrase-level cues (checked against the lowercased sentence start). + const PHRASE_CUES: &[&str] = &[ + "i need to ", + "i should ", + "i have to ", + "i must ", + "need to ", + "got to ", + "have to ", + "must ", + "let me ", + "let's ", + "lets ", + "remember to ", + "don't forget to ", + "dont forget to ", + "don't forget ", + "dont forget ", + "make sure to ", + "make sure i ", + "todo:", + "to-do:", + "task:", + ]; + + // Bare imperative verbs expected at the start of a sentence. + const IMPERATIVE_VERBS: &[&str] = &[ + "send", + "write", + "call", + "email", + "fix", + "update", + "review", + "check", + "finish", + "schedule", + "book", + "order", + "buy", + "ask", + "follow up", + "followup", + "create", + "add", + "remove", + "delete", + "submit", + "upload", + "download", + "install", + "configure", + "test", + "deploy", + "merge", + "close", + "open", + "share", + "contact", + "reach out", + "prepare", + "draft", + "complete", + "reply", + "respond", + ]; + + // Split on sentence-terminating punctuation and newlines. + let sentences: Vec<&str> = transcript.split(['.', '?', '!', '\n']).collect(); + + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut results: Vec = Vec::new(); + + for raw in sentences { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + + // Build a lowercase version for matching, stripping leading filler. + let mut lc = trimmed.to_lowercase(); + for filler in FILLER { + if lc.starts_with(filler) { + lc = lc[filler.len()..].trim_start().to_string(); + break; + } + } + + let is_task = PHRASE_CUES.iter().any(|cue| lc.starts_with(cue)) + || IMPERATIVE_VERBS.iter().any(|verb| { + lc.starts_with(verb) + && lc + .as_bytes() + .get(verb.len()) + .map(|&b| b == b' ' || b == b',') + .unwrap_or(true) + }); + + if is_task { + let key = lc.clone(); + if seen.insert(key) { + results.push(trimmed.to_string()); + if results.len() >= MAX_RULE_BASED_TASKS { + break; + } + } + } + } + + results +} + #[cfg(test)] mod tests { use super::*; @@ -548,6 +950,86 @@ mod tests { assert_eq!(index, Some(5)); } + #[test] + fn json_envelope_complete_detects_finished_object() { + assert!(json_envelope_complete( + r#"{"topic":"meeting","intent":"planning"}"# + )); + } + + #[test] + fn json_envelope_complete_detects_finished_array() { + assert!(json_envelope_complete(r#"["Call plumber","Buy milk"]"#)); + } + + #[test] + fn json_envelope_complete_ignores_braces_inside_strings() { + assert!(!json_envelope_complete(r#"{"topic":"literal } brace""#)); + } + + #[test] + fn json_envelope_complete_rejects_prefixes_and_trailing_text() { + assert!(!json_envelope_complete(r#"{"topic":"meeting""#)); + assert!(!json_envelope_complete(r#"{"topic":"meeting"} extra"#)); + } + + #[test] + fn extract_json_envelope_skips_qwen_thinking_prefix() { + let raw = + "\n\n\n\n{\"topic\":\"grant-application\",\"intent\":\"planning\"}"; + assert_eq!( + extract_json_envelope(raw), + Some("{\"topic\":\"grant-application\",\"intent\":\"planning\"}"), + ); + } + + #[test] + fn extract_json_envelope_handles_arrays_and_trailing_stop_text() { + assert_eq!( + extract_json_envelope("prefix [\"Call plumber\",\"Buy milk\"]<|im_end|>"), + Some("[\"Call plumber\",\"Buy milk\"]"), + ); + } + + /// Phase B.9 audit regression (2026-05-14). The original + /// `extract_json_envelope_skips_qwen_thinking_prefix` test only + /// covered an EMPTY `` block. Qwen-style reasoning + /// is typically non-empty and can contain JSON-looking literals + /// (the model thinking out loud about what shape it should emit). + /// The naive "first '{' wins" extractor mis-identified the + /// reasoning's literal as the answer envelope and returned it, + /// skipping the actual answer that followed ``. + /// + /// Post-fix the extractor strips the leading `` + /// block before scanning, so the reasoning's literal cannot + /// poison the result. + #[test] + fn extract_json_envelope_skips_thinking_block_with_json_looking_content() { + let raw = "The answer should look like {\"topic\":\"reasoning-example\",\"intent\":\"capture\"} \ + based on the schema.{\"topic\":\"real-answer\",\"intent\":\"planning\"}"; + assert_eq!( + extract_json_envelope(raw), + Some("{\"topic\":\"real-answer\",\"intent\":\"planning\"}"), + ); + } + + /// Phase B.9 audit regression (2026-05-14). If the reasoning block + /// contains UNBALANCED braces (e.g. the model writes "I wonder + /// about {..." inside ``), the pre-strip extractor + /// would start its stack on that unbalanced `{`, never find a + /// matching `}`, and continue past `` polluting the stack + /// with the real answer's braces — ultimately returning None and + /// losing the answer entirely. Stripping the reasoning block first + /// makes both cases moot. + #[test] + fn extract_json_envelope_survives_unbalanced_braces_in_thinking() { + let raw = "I wonder about {something unfinished here{\"topic\":\"recovery\",\"intent\":\"capture\"}"; + assert_eq!( + extract_json_envelope(raw), + Some("{\"topic\":\"recovery\",\"intent\":\"capture\"}"), + ); + } + #[test] fn prompt_preflight_rejects_oversized_prompt_tokens() { let err = preflight_context_window(7_105, 1_024).unwrap_err(); @@ -567,4 +1049,224 @@ mod tests { let n_ctx = preflight_context_window(7_104, 1_024).unwrap(); assert_eq!(n_ctx, MAX_CONTEXT_TOKENS); } + + /// Race-3 regression. The inner `std::sync::Mutex` MUST NOT be held + /// across the slow `LlamaModel::load_from_file` call: sync Tauri + /// command handlers like `get_llm_status`, `check_llm_model`, + /// `delete_llm_model`, and `test_llm_model` call `is_loaded()` / + /// `loaded_model_id()` from tokio worker threads without + /// `spawn_blocking`. If the lock is held for the duration of a + /// 5-15 s load, parallel status polls from the frontend park the + /// tokio executor and the whole UI deadlocks. + /// + /// Pre-fix this test FAILS — both probes time out because the load + /// holds the mutex. Post-fix it PASSES — probes return in ≤50 ms. + #[test] + fn is_loaded_does_not_block_on_slow_load() { + use std::sync::mpsc; + use std::thread; + use std::time::{Duration, Instant}; + + let engine = LlmEngine::new(); + let load_started = Arc::new(std::sync::Barrier::new(2)); + let release_load = Arc::new(std::sync::Barrier::new(2)); + + let engine_for_loader = engine.clone(); + let load_started_for_loader = Arc::clone(&load_started); + let release_load_for_loader = Arc::clone(&release_load); + + let loader_handle = thread::spawn(move || { + engine_for_loader + .__test_run_with_lock_discipline(|| { + // Signal the probe thread that the load is mid-flight + // (loading flag claimed, inner mutex released). + load_started_for_loader.wait(); + // Wait until the probe thread says it's done so the + // load's "duration" is bounded by the probes. + release_load_for_loader.wait(); + }) + .unwrap(); + }); + + // Wait until the loader is inside its slow section. + load_started.wait(); + + // Now probe `is_loaded()` and `loaded_model_id()` from this + // thread. They MUST return without contending on the lock. + let probe_deadline = Duration::from_millis(50); + let (tx, rx) = mpsc::channel(); + let engine_for_probe = engine.clone(); + let probe_handle = thread::spawn(move || { + let start = Instant::now(); + let loaded = engine_for_probe.is_loaded(); + let id = engine_for_probe.loaded_model_id(); + let loading = engine_for_probe.is_loading(); + let elapsed = start.elapsed(); + tx.send((loaded, id, loading, elapsed)).unwrap(); + }); + + let result = rx + .recv_timeout(probe_deadline) + .expect("is_loaded / loaded_model_id probe must return within 50 ms"); + let (loaded, id, loading, elapsed) = result; + + assert!( + !loaded, + "is_loaded() should report false while a load is in flight" + ); + assert_eq!(id, None, "loaded_model_id() should be None mid-load"); + assert!(loading, "is_loading() should report true mid-load"); + assert!( + elapsed < probe_deadline, + "probe took {elapsed:?}, expected < {probe_deadline:?}" + ); + + probe_handle.join().unwrap(); + release_load.wait(); + loader_handle.join().unwrap(); + + // After the load completes the flag clears. + assert!(!engine.is_loading()); + } + + /// Race-3 / Race-4 — concurrent load attempts must not both reach + /// the heavy work. The second caller should be told `AlreadyLoading` + /// rather than starting a parallel load that silently overwrites + /// the first. + #[test] + fn second_concurrent_load_is_refused() { + use std::thread; + + let engine = LlmEngine::new(); + let load_started = Arc::new(std::sync::Barrier::new(2)); + let release_load = Arc::new(std::sync::Barrier::new(2)); + + let engine_for_loader = engine.clone(); + let load_started_for_loader = Arc::clone(&load_started); + let release_load_for_loader = Arc::clone(&release_load); + + let loader_handle = thread::spawn(move || { + engine_for_loader + .__test_run_with_lock_discipline(|| { + load_started_for_loader.wait(); + release_load_for_loader.wait(); + }) + .unwrap(); + }); + + load_started.wait(); + + // Second concurrent attempt MUST be refused, not parallel-load. + let second = engine.__test_run_with_lock_discipline(|| { + panic!("second concurrent load should never reach its op"); + }); + assert!(matches!(second, Err(EngineError::AlreadyLoading))); + + release_load.wait(); + loader_handle.join().unwrap(); + + // After the first load completes, a fresh attempt is allowed. + assert!(engine.__test_run_with_lock_discipline(|| {}).is_ok()); + } + + /// Phase B.7 audit regression (2026-05-14). The cde985d fix + /// introduced the `loading` AtomicBool to refuse a second concurrent + /// load, but left `unload()` blind to the flag. A concurrent unload + /// during a load's slow window observed `model == None` (the load's + /// step 3 had already cleared state), no-op-cleared the same nulls, + /// returned Ok — and then the load's step 5 silently installed the + /// new state. The caller saw unload-success but the engine ended up + /// loaded. + /// + /// Post-fix: an unload mid-load is refused with + /// `EngineError::AlreadyLoading`. The caller can retry once the + /// load completes (signalled by `is_loading() == false`). + #[test] + fn unload_during_load_is_refused() { + use std::thread; + + let engine = LlmEngine::new(); + let load_started = Arc::new(std::sync::Barrier::new(2)); + let release_load = Arc::new(std::sync::Barrier::new(2)); + + let engine_for_loader = engine.clone(); + let load_started_for_loader = Arc::clone(&load_started); + let release_load_for_loader = Arc::clone(&release_load); + + let loader_handle = thread::spawn(move || { + engine_for_loader + .__test_run_with_lock_discipline(|| { + load_started_for_loader.wait(); + release_load_for_loader.wait(); + }) + .unwrap(); + }); + + // Wait until the loader is mid-slow-section (loading flag claimed, + // engine state cleared). + load_started.wait(); + + // Unload while the load is in flight MUST be refused, not silently + // no-op'd and then overwritten by the load's install step. + let result = engine.unload(); + assert!( + matches!(result, Err(EngineError::AlreadyLoading)), + "unload during a load must surface AlreadyLoading, got {result:?}" + ); + + release_load.wait(); + loader_handle.join().unwrap(); + + // After the load completes the flag clears and unload succeeds. + assert!(!engine.is_loading()); + engine + .unload() + .expect("unload after load completes must succeed"); + } + + // ── rule_based_extract_tasks ────────────────────────────────────────── + + #[test] + fn rule_based_extract_finds_explicit_imperatives() { + let t = "I need to send Sarah the report tomorrow. Don't forget the slide deck."; + let tasks = rule_based_extract_tasks(t); + assert_eq!(tasks.len(), 2, "expected 2 tasks, got: {tasks:?}"); + assert!( + tasks[0].to_lowercase().contains("send sarah"), + "first task should mention 'send sarah': {tasks:?}" + ); + assert!( + tasks[1].to_lowercase().contains("slide deck"), + "second task should mention 'slide deck': {tasks:?}" + ); + } + + #[test] + fn rule_based_extract_caps_at_max() { + let t = "Send email. Write doc. Call client. Fix bug. Update spec. Review PR. Check tests. Finish report. Schedule meeting. Book hotel. Order parts. Buy supplies."; + let tasks = rule_based_extract_tasks(t); + assert!( + tasks.len() <= MAX_RULE_BASED_TASKS, + "expected at most {MAX_RULE_BASED_TASKS} tasks, got {}", + tasks.len() + ); + } + + #[test] + fn rule_based_extract_returns_empty_for_no_imperatives() { + let t = "The weather is lovely today. The garden looks nice."; + let tasks = rule_based_extract_tasks(t); + assert_eq!(tasks.len(), 0, "expected 0 tasks, got: {tasks:?}"); + } + + #[test] + fn rule_based_extract_dedupes_repeated_sentences() { + let t = "I need to send the report. I need to send the report."; + let tasks = rule_based_extract_tasks(t); + assert_eq!( + tasks.len(), + 1, + "expected 1 deduplicated task, got: {tasks:?}" + ); + } } diff --git a/crates/llm/src/model_manager.rs b/crates/llm/src/model_manager.rs index b250d2c..16ba50d 100644 --- a/crates/llm/src/model_manager.rs +++ b/crates/llm/src/model_manager.rs @@ -240,7 +240,7 @@ pub fn recommend_tier(total_ram_bytes: u64, total_vram_bytes: Option) -> Ll } pub fn model_dir() -> PathBuf { - magnotia_core::paths::app_paths().llm_models_dir() + lumotia_core::paths::app_paths().llm_models_dir() } pub fn model_path(id: LlmModelId) -> PathBuf { @@ -276,16 +276,44 @@ where let _reservation = DownloadReservation::acquire(id)?; let dest = model_path(id); tokio::fs::create_dir_all(model_dir()).await?; + download_to(id.hf_url(), id.sha256(), &dest, on_progress).await +} +/// Inner driver split out of `download_model` so the +/// existing-file / SHA-mismatch / new-download decision can be +/// exercised by tests without hitting the hardcoded Hugging Face URLs +/// on `LlmModelId`. Behaviour: +/// 1. If `dest` already exists and its SHA matches — done, no network. +/// 2. If `dest` exists but the SHA mismatches — DO NOT delete; fall +/// through to `download_impl` which writes via `.part` and renames +/// atomically on success. Rev-1 reversibility kill (atomiser +/// 2026-05-12): the previous implementation called +/// `remove_file(&dest)` here before the network round-trip. A +/// network blip / power loss / disk-full between the unlink and +/// the eventual `rename` left users with neither the old +/// (corrupted-but-readable) model nor the new one — a 1.5–20 GB +/// redownload from scratch with no fallback. +/// 3. If `dest` doesn't exist — straight to `download_impl`. +async fn download_to( + url: &str, + expected_sha: &str, + dest: &Path, + on_progress: F, +) -> Result<(), DownloadError> +where + F: FnMut(u64, u64) + Send + 'static, +{ if dest.exists() { - let actual = sha256_file(&dest).await?; - if actual == id.sha256() { + let actual = sha256_file(dest).await?; + if actual == expected_sha { return Ok(()); } - tokio::fs::remove_file(&dest).await?; + // SHA mismatch: do NOT unlink. `download_impl` writes to a + // `.part` sibling and atomically renames over `dest` once the + // new payload verifies. On failure the user keeps the old + // file (even if "corrupt") rather than ending up with nothing. } - - download_impl(id.hf_url(), id.sha256(), &dest, on_progress).await + download_impl(url, expected_sha, dest, on_progress).await } async fn sha256_file(path: &Path) -> Result { @@ -321,7 +349,7 @@ where .unwrap_or(0); let client = reqwest::Client::builder() - .user_agent("magnotia/0.1.0") + .user_agent("lumotia/0.1.0") .connect_timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| DownloadError::Http(e.to_string()))?; @@ -336,6 +364,18 @@ where .await .map_err(|e| DownloadError::Http(e.to_string()))?; if resume_from > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT { + // Server downgraded from Range-aware to full-body 200 (typically a + // mirror / CDN that advertises `Accept-Ranges` but doesn't honour a + // mid-stream resume). The existing `.part` bytes are stale — they + // cannot be stitched onto a fresh 200 stream. Unlink them BEFORE + // returning so the next `download_model()` call starts from + // `resume_from = 0` and succeeds. Without this unlink the user is + // wedged: every retry sends the same Range header, the server + // returns 200 again, and `ResumeUnsupported` fires forever until + // the user manually calls `delete_model()`. That is itself a + // reversibility kill in the same family as Rev-1 (atomiser + // 2026-05-12); fixed in Phase B.3 audit. + tokio::fs::remove_file(&tmp).await.ok(); return Err(DownloadError::ResumeUnsupported); } if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT @@ -483,4 +523,134 @@ mod tests { server_task.await.unwrap(); } + + /// Phase B.3 audit residual (2026-05-14). The original Rev-1 fix + /// stopped the pre-emptive unlink of `dest` on SHA mismatch, but it + /// did NOT clean up `.part` when `download_impl` returned + /// `ResumeUnsupported`. That meant a transient mirror downgrade + /// (server returns 200 to a Range request) left a stale `.part` on + /// disk that every subsequent retry kept feeding back into the same + /// failing Range request — wedged until the user manually called + /// `delete_model()`. Same reversibility-kill family as Rev-1. + /// + /// We spin a server that ignores the Range header and returns 200 + /// with full body. With a pre-existing `.part` the call must fail + /// with `ResumeUnsupported` AND the stale `.part` must be gone, so + /// a follow-up call would compute `resume_from = 0` and start + /// fresh. + #[tokio::test] + async fn resume_unsupported_unlinks_part_so_retry_starts_fresh() { + let body = b"fresh full body returned by server ignoring Range header".to_vec(); + let expected_sha = format!("{:x}", Sha256::digest(&body)); + + let server = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = server.local_addr().unwrap(); + let content = body.clone(); + + let server_task = tokio::spawn(async move { + let (mut socket, _) = server.accept().await.unwrap(); + let mut request = vec![0u8; 2048]; + let _ = socket.read(&mut request).await.unwrap(); + // Deliberately ignore Range header and return 200 with the + // full body — the case the downloader must recover from + // without leaving a stuck `.part`. + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", + content.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + socket.write_all(&content).await.unwrap(); + }); + + let dir = tempdir().unwrap(); + let dest = dir.path().join("fixture.gguf"); + let part = dest.with_extension("gguf.part"); + // Pretend a previous interrupted attempt left 10 stale bytes. + tokio::fs::write(&part, b"STALEBYTES").await.unwrap(); + assert!(part.exists()); + + let err = download_impl( + &format!("http://{addr}/fixture.gguf"), + &expected_sha, + &dest, + |_, _| {}, + ) + .await + .expect_err("server ignoring Range must surface ResumeUnsupported"); + + assert!( + matches!(err, DownloadError::ResumeUnsupported), + "expected ResumeUnsupported, got: {err:?}" + ); + assert!( + !part.exists(), + "ResumeUnsupported must unlink .part so the next attempt starts fresh" + ); + assert!( + !dest.exists(), + "dest must not have been written — only the unlink should run" + ); + + server_task.await.unwrap(); + } + + /// Rev-1 regression (atomiser 2026-05-12). Before the fix the + /// SHA-mismatch path in `download_model` deleted the existing + /// file BEFORE the network call. A failing download then left + /// the user with neither the old nor the new model. + /// + /// We exercise `download_to` (the testable inner driver) with + /// an existing sentinel file at `dest` whose SHA does NOT match + /// the expected one, against a server that returns HTTP 500. + /// The function must fail; the destination must still exist + /// with its original contents. + #[tokio::test] + async fn download_failure_preserves_existing_file() { + let server = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = server.local_addr().unwrap(); + + let server_task = tokio::spawn(async move { + let (mut socket, _) = server.accept().await.unwrap(); + let mut buf = vec![0u8; 2048]; + let _ = socket.read(&mut buf).await.unwrap(); + let body = b"upstream blew up"; + let response = format!( + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + socket.write_all(body).await.unwrap(); + }); + + let dir = tempdir().unwrap(); + let dest = dir.path().join("fixture.gguf"); + // Sentinel "old model" file the user already had on disk. + tokio::fs::write(&dest, b"OLD").await.unwrap(); + + // Expect-sha is deliberately something the OLD file does NOT + // hash to, so the existing-file branch falls through to + // download_impl (the exact case the atomiser flagged). + let expected_sha = "0".repeat(64); + + download_to( + &format!("http://{addr}/fixture.gguf"), + &expected_sha, + &dest, + |_, _| {}, + ) + .await + .expect_err("500 response must fail the download"); + + assert!( + dest.exists(), + "download failure must leave the existing dest in place" + ); + let preserved = tokio::fs::read(&dest).await.unwrap(); + assert_eq!( + preserved, b"OLD", + "existing file contents must be untouched on failed download" + ); + + server_task.await.unwrap(); + } } diff --git a/crates/llm/src/prompts.rs b/crates/llm/src/prompts.rs index a93c947..bc90417 100644 --- a/crates/llm/src/prompts.rs +++ b/crates/llm/src/prompts.rs @@ -45,9 +45,9 @@ context that are not explicit commitments. Output an empty array if there are \ no action items."; /// Compact representation of a human-in-the-loop feedback example used -/// for few-shot prompt conditioning. Built by magnotia-storage and fed to the +/// for few-shot prompt conditioning. Built by lumotia-storage and fed to the /// prompt builder below; we keep this struct local to the LLM crate so -/// magnotia-llm does not depend on magnotia-storage. +/// lumotia-llm does not depend on lumotia-storage. #[derive(Debug, Clone)] pub struct FeedbackExample { /// What the AI was given as input (e.g. the parent task text, or diff --git a/crates/llm/tests/content_tags_smoke.rs b/crates/llm/tests/content_tags_smoke.rs index 3f90cb5..0332dc8 100644 --- a/crates/llm/tests/content_tags_smoke.rs +++ b/crates/llm/tests/content_tags_smoke.rs @@ -1,23 +1,23 @@ //! Smoke test for Phase 9 LlmEngine::extract_content_tags. //! -//! Gated behind the same `MAGNOTIA_LLM_TEST_MODEL` env var as the existing +//! Gated behind the same `LUMOTIA_LLM_TEST_MODEL` env var as the existing //! smoke.rs test so neither runs in default `cargo test` runs (model //! load is heavy). Run explicitly with: //! -//! MAGNOTIA_LLM_TEST_MODEL=/path/to/model.gguf cargo test -p magnotia-llm \ +//! LUMOTIA_LLM_TEST_MODEL=/path/to/model.gguf cargo test -p lumotia-llm \ //! --test content_tags_smoke -- --nocapture use std::env; use std::path::PathBuf; -use magnotia_llm::{is_valid_intent, LlmEngine, LlmModelId}; +use lumotia_llm::{is_valid_intent, LlmEngine, LlmModelId}; #[test] fn extract_content_tags_returns_valid_pair() { - let model_path = match env::var("MAGNOTIA_LLM_TEST_MODEL") { + let model_path = match env::var("LUMOTIA_LLM_TEST_MODEL") { Ok(path) => PathBuf::from(path), Err(_) => { - eprintln!("MAGNOTIA_LLM_TEST_MODEL not set — skipping"); + eprintln!("LUMOTIA_LLM_TEST_MODEL not set — skipping"); return; } }; diff --git a/crates/llm/tests/smoke.rs b/crates/llm/tests/smoke.rs index 3e01a07..f8a6e12 100644 --- a/crates/llm/tests/smoke.rs +++ b/crates/llm/tests/smoke.rs @@ -6,20 +6,20 @@ //! - `context::params::LlamaContextParams` //! - `sampling::LlamaSampler` //! -//! The test is gated behind `MAGNOTIA_LLM_TEST_MODEL`. +//! The test is gated behind `LUMOTIA_LLM_TEST_MODEL`. use std::env; use std::path::PathBuf; -use magnotia_llm::LlmEngine; -use magnotia_llm::LlmModelId; +use lumotia_llm::LlmEngine; +use lumotia_llm::LlmModelId; #[test] fn llama_cpp_2_smoke_generates_and_wraps() { - let model_path = match env::var("MAGNOTIA_LLM_TEST_MODEL") { + let model_path = match env::var("LUMOTIA_LLM_TEST_MODEL") { Ok(path) => PathBuf::from(path), Err(_) => { - eprintln!("MAGNOTIA_LLM_TEST_MODEL not set — skipping"); + eprintln!("LUMOTIA_LLM_TEST_MODEL not set — skipping"); return; } }; @@ -32,7 +32,7 @@ fn llama_cpp_2_smoke_generates_and_wraps() { let completion = engine .generate( "Write exactly one short greeting.", - &magnotia_llm::GenerationConfig { + &lumotia_llm::GenerationConfig { max_tokens: 32, temperature: 0.0, stop_sequences: vec!["\n".to_string()], diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 9d8f036..d371049 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -1,18 +1,20 @@ [package] -name = "magnotia-mcp" -version = "0.1.0" -edition = "2021" -description = "Read-only MCP stdio server exposing Magnotia transcripts and tasks to external agents" +name = "lumotia-mcp" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +description = "Read-only MCP stdio server exposing Lumotia transcripts and tasks to external agents" [[bin]] -name = "magnotia-mcp" +name = "lumotia-mcp" path = "src/main.rs" [lib] path = "src/lib.rs" [dependencies] -magnotia-storage = { path = "../storage" } +lumotia-storage = { path = "../storage" } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index f022ad8..c166e10 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -1,8 +1,8 @@ -//! Minimal Model Context Protocol server exposing Magnotia's local SQLite store. +//! Minimal Model Context Protocol server exposing Lumotia's local SQLite store. //! //! Scope: **read-only** tools. An external agent (Claude desktop, Cline, any //! MCP-capable client) can list / search / fetch transcripts and list tasks. -//! No writes — Magnotia's Tauri app remains the only writer. +//! No writes — Lumotia's Tauri app remains the only writer. //! //! Transport: newline-delimited JSON-RPC 2.0 over stdio, per the stdio //! transport spec. Server spec version: 2024-11-05. @@ -12,7 +12,7 @@ use serde_json::{json, Value}; use sqlx::SqlitePool; pub const PROTOCOL_VERSION: &str = "2024-11-05"; -pub const SERVER_NAME: &str = "magnotia-mcp"; +pub const SERVER_NAME: &str = "lumotia-mcp"; pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); #[derive(Debug, Deserialize)] @@ -95,7 +95,7 @@ fn initialize_result() -> Value { "version": SERVER_VERSION, }, "instructions": - "Read-only access to Magnotia's local transcript history and task list. \ + "Read-only access to Lumotia's local transcript history and task list. \ All data stays on the user's machine.", }) } @@ -105,7 +105,7 @@ fn tools_list_result() -> Value { "tools": [ { "name": "list_transcripts", - "description": "List recent transcripts from Magnotia's local history, most recent first. \ + "description": "List recent transcripts from Lumotia's local history, most recent first. \ Returns summaries (id, title, created_at, duration, preview).", "inputSchema": { "type": "object", @@ -135,7 +135,7 @@ fn tools_list_result() -> Value { }, { "name": "search_transcripts", - "description": "Full-text search across Magnotia's transcripts. Returns matching summaries.", + "description": "Full-text search across Lumotia's transcripts. Returns matching summaries.", "inputSchema": { "type": "object", "required": ["query"], @@ -155,7 +155,7 @@ fn tools_list_result() -> Value { }, { "name": "list_tasks", - "description": "List tasks from Magnotia's task store. Returns both open and completed.", + "description": "List tasks from Lumotia's task store. Returns both open and completed.", "inputSchema": { "type": "object", "properties": {}, @@ -206,7 +206,7 @@ async fn list_transcripts_tool(pool: &SqlitePool, args: Value) -> Result Result Result Result Result { - let rows = magnotia_storage::list_tasks(pool) + let rows = lumotia_storage::list_tasks(pool) .await .map_err(|e| error(-32603, format!("DB error: {e}")))?; @@ -460,7 +460,7 @@ mod tests { }); let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); - magnotia_storage::migrations::run_migrations(&pool) + lumotia_storage::migrations::run_migrations(&pool) .await .unwrap(); let response = handle_message(&pool, request).await.expect("has response"); diff --git a/crates/mcp/src/main.rs b/crates/mcp/src/main.rs index 3439ba4..3afc48d 100644 --- a/crates/mcp/src/main.rs +++ b/crates/mcp/src/main.rs @@ -1,22 +1,22 @@ -//! Stdio entry point for magnotia-mcp. Reads newline-delimited JSON-RPC messages -//! from stdin, dispatches via `magnotia_mcp::handle_message`, writes responses to +//! Stdio entry point for lumotia-mcp. Reads newline-delimited JSON-RPC messages +//! from stdin, dispatches via `lumotia_mcp::handle_message`, writes responses to //! stdout. Logs land on stderr so they don't collide with the JSON-RPC stream. use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; #[tokio::main(flavor = "current_thread")] async fn main() -> anyhow::Result<()> { - let db_path = magnotia_storage::database_path(); + let db_path = lumotia_storage::database_path(); eprintln!( - "[magnotia-mcp] opening Magnotia database at {} (read-only)", + "[lumotia-mcp] opening Lumotia database at {} (read-only)", db_path.display() ); // Open read-only at the connection level so the MCP server cannot write // to the user's database, regardless of which tools the dispatcher // exposes. Migrations are deliberately skipped — this binary never owns // the schema; the main app is the single migration writer. - let pool = magnotia_storage::init_readonly(&db_path).await?; - eprintln!("[magnotia-mcp] ready, waiting for JSON-RPC on stdin"); + let pool = lumotia_storage::init_readonly(&db_path).await?; + eprintln!("[lumotia-mcp] ready, waiting for JSON-RPC on stdin"); let mut lines = BufReader::new(tokio::io::stdin()).lines(); let mut stdout = tokio::io::stdout(); @@ -28,7 +28,7 @@ async fn main() -> anyhow::Result<()> { } let response = match serde_json::from_str::(trimmed) { - Ok(raw) => match magnotia_mcp::handle_message(&pool, raw).await { + Ok(raw) => match lumotia_mcp::handle_message(&pool, raw).await { Some(response) => response, None => continue, // notification — no reply }, @@ -38,8 +38,8 @@ async fn main() -> anyhow::Result<()> { // logged and continued, dropping the response — // clients saw silence instead of a structured error // (2026-04-22 review MAJOR). - eprintln!("[magnotia-mcp] parse error: {err}"); - magnotia_mcp::parse_error_response(&err.to_string()) + eprintln!("[lumotia-mcp] parse error: {err}"); + lumotia_mcp::parse_error_response(&err.to_string()) } }; diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 990b730..68e35c2 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -1,11 +1,13 @@ [package] -name = "magnotia-storage" -version = "0.1.0" -edition = "2021" -description = "SQLite persistence, BM25 search, and file storage for Magnotia" +name = "lumotia-storage" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +description = "SQLite persistence, BM25 search, and file storage for Lumotia" [dependencies] -magnotia-core = { path = "../core" } +lumotia-core = { path = "../core" } # SQLite with compile-time checked queries # default-features = false strips sqlx's `any`, `macros`, `migrate`, `json` — @@ -21,11 +23,26 @@ tokio = { version = "1", features = ["rt", "sync", "macros"] } # Serialisation (DailyCompletionCount exposed to frontend via Tauri commands) serde = { version = "1", features = ["derive"] } -# Logging -log = "0.4" +# Structured logging via `tracing` so storage events bridge into the +# subscriber installed by src-tauri/src/lib.rs::install_subscriber and +# land in both stderr and the rolling lumotia.log forensic stream. The +# storage crate was on the `log` crate up to Phase B.8; without a +# log→tracing bridge (e.g. tracing-log::LogTracer) those events +# vanished even though the EnvFilter directive `lumotia_storage=info` +# advertised them as visible. +tracing = "0.1" -# Structured error derivation for magnotia_storage::Error. +# Structured error derivation for lumotia_storage::Error. thiserror = "1" # UUIDs for profile + profile_terms ids (v7 random). uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +# Real-file tempdirs for integration tests that need an on-disk DB (the +# in-memory `sqlite::memory:` connection in src/database.rs unit tests +# doesn't cover the rename-then-reopen path the rebrand migration exercises). +tempfile = "3" +# `rt-multi-thread` is required for the `#[tokio::test(flavor = "multi_thread")]` +# variants that drive blocking lumotia_core::paths migrations from async tests. +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros"] } diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 87fc35b..708f4db 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -42,7 +42,7 @@ pub async fn init(db_path: &Path) -> Result { /// Open the SQLite database in read-only mode without running migrations. /// -/// Used by `magnotia-mcp` so the MCP server cannot write to the user's database +/// Used by `lumotia-mcp` so the MCP server cannot write to the user's database /// regardless of which tools the dispatcher exposes — `read_only(true)` makes /// the constraint structural rather than relying on the request handler being /// well-behaved. Fails cleanly if the DB doesn't exist (no `create_if_missing`). @@ -129,9 +129,13 @@ pub async fn insert_transcript( Ok(()) } +/// Fetch a transcript by id, EXCLUDING soft-deleted (deleted_at IS NOT NULL) +/// rows. Soft-deleted rows are still in the table — they are scoped to the +/// trash view via `list_trashed_transcripts` / `restore_transcript` — but the +/// regular read path treats them as gone. Rev-2 (atomiser 2026-05-12). pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result> { let row = sqlx::query( - "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags FROM transcripts WHERE id = ?", + "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags FROM transcripts WHERE id = ? AND deleted_at IS NULL", ) .bind(id) .fetch_optional(pool) @@ -155,8 +159,11 @@ pub async fn list_transcripts_paged( limit: i64, offset: i64, ) -> Result> { + // `deleted_at IS NULL` filter is the Rev-2 soft-delete contract: rows + // in the trash are kept in the table for restore, but the regular list + // path treats them as gone. let rows = sqlx::query( - "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?", + "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags FROM transcripts WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?", ) .bind(limit) .bind(offset) @@ -171,8 +178,10 @@ pub async fn list_transcripts_paged( } /// Total count of transcripts. Useful for displaying "showing 50 of 312" in the UI. +/// Excludes soft-deleted (deleted_at IS NOT NULL) rows — they only count +/// toward the trash view. pub async fn count_transcripts(pool: &SqlitePool) -> Result { - let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts") + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE deleted_at IS NULL") .fetch_one(pool) .await .map_err(|source| Error::Query { @@ -293,13 +302,185 @@ pub async fn update_transcript_meta( }) } +/// Soft-delete a transcript and best-effort remove its audio file. +/// +/// Rev-2 / Rev-3 reversibility kill (atomiser 2026-05-12). The previous +/// implementation issued a hard `DELETE FROM transcripts WHERE id = ?` +/// and never touched the WAV file at `audio_path`. Two failure modes +/// fanned out from that: +/// +/// * Rev-2: a single click on the History "Clear All" / "Confirm" +/// button immediately erased months of dictation with no trash, +/// no export, no undo. +/// * Rev-3: even single-row deletes left the audio file on disk; +/// the recordings dir grew monotonically. +/// +/// The new contract: +/// +/// 1. Capture the existing row (so we still have `audio_path` even if +/// the soft-delete is observed by a parallel reader before we get +/// here). +/// 2. UPDATE deleted_at = datetime('now'). FTS triggers fire on UPDATE +/// and re-index the row, but `search_transcripts` / `list_transcripts` +/// filter `deleted_at IS NULL`, so the trash rows don't surface. +/// 3. Best-effort `tokio::fs::remove_file(audio_path)`. A failure +/// (file already gone, permission denied) is logged but does NOT +/// propagate — the DB soft-delete has already succeeded, and +/// `purge_deleted_transcripts` will retry the removal later via +/// its own audio_path lookup before hard-deleting the row. +/// +/// Returns `Ok(())` even when no row matched the id (idempotent). pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> { - sqlx::query("DELETE FROM transcripts WHERE id = ?") + // Capture audio_path FIRST. We deliberately bypass `get_transcript` + // (which filters deleted_at IS NULL) so a double-delete still finds + // the row and the second call is a no-op cleanup rather than an + // error. + let audio_path: Option = + sqlx::query_scalar("SELECT audio_path FROM transcripts WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await + .map_err(|source| Error::Query { + operation: "delete_transcript".into(), + source, + })? + .flatten(); + + let res = sqlx::query( + "UPDATE transcripts SET deleted_at = datetime('now') \ + WHERE id = ? AND deleted_at IS NULL", + ) + .bind(id) + .execute(pool) + .await + .map_err(|source| Error::Query { + operation: "delete_transcript".into(), + source, + })?; + + // Best-effort audio cleanup. Only attempt removal when the + // soft-delete actually flipped a row from live -> trashed; on a + // repeat soft-delete the file is already gone (or never existed) + // and there's nothing to do. + if res.rows_affected() > 0 { + if let Some(path) = audio_path.as_deref() { + if let Err(err) = tokio::fs::remove_file(path).await { + if err.kind() != std::io::ErrorKind::NotFound { + tracing::warn!( + target: "lumotia_storage", + "delete_transcript: failed to remove audio file at {path}: {err}" + ); + } + } + } + } + + Ok(()) +} + +/// Hard-delete soft-deleted rows older than `older_than_days`. Intended to +/// run once per startup (or on a daily cron). Returns the number of rows +/// removed. +/// +/// FK cascades: `segments` ON DELETE CASCADE (v1) fires; tasks with +/// `source_transcript_id` ON DELETE SET NULL (v8) is preserved. +/// +/// Audio files are also best-effort removed here in case the original +/// soft-delete failed at the filesystem layer (Rev-3 belt-and-braces). +/// +/// **Atomicity (Phase B.4 audit fix 2026-05-14):** the prior form was a +/// two-statement SELECT-then-DELETE-WHERE-id-IN sequence. If a row was +/// restored between the SELECT and the DELETE (`restore_transcript` +/// clearing `deleted_at`), the DELETE still hard-deleted the now-live +/// row and removed its audio file — bypassing the soft-delete safety +/// contract Rev-2 was added to enforce. We now use a single +/// `DELETE … RETURNING` so the row filter is re-evaluated atomically at +/// DELETE time and the returned `audio_path`s are guaranteed to belong +/// to rows that this call actually hard-deleted. This also removes the +/// chunking concern (no `IN(…)` clause means no SQLITE_MAX_VARIABLE_NUMBER +/// ceiling). +pub async fn purge_deleted_transcripts(pool: &SqlitePool, older_than_days: i64) -> Result { + let rows = sqlx::query( + "DELETE FROM transcripts \ + WHERE deleted_at IS NOT NULL \ + AND deleted_at < datetime('now', ?) \ + RETURNING audio_path", + ) + .bind(format!("-{older_than_days} days")) + .fetch_all(pool) + .await + .map_err(|source| Error::Query { + operation: "purge_deleted_transcripts".into(), + source, + })?; + + if rows.is_empty() { + return Ok(0); + } + + let mut audio_paths: Vec = Vec::new(); + for row in &rows { + let audio: Option = row.get("audio_path"); + if let Some(p) = audio { + audio_paths.push(p); + } + } + + // Best-effort audio cleanup. NotFound is the expected case for rows + // whose audio was already removed at soft-delete time. + for path in &audio_paths { + if let Err(err) = tokio::fs::remove_file(path).await { + if err.kind() != std::io::ErrorKind::NotFound { + tracing::warn!( + target: "lumotia_storage", + "purge_deleted_transcripts: failed to remove audio file at {path}: {err}" + ); + } + } + } + + Ok(rows.len() as u64) +} + +/// List soft-deleted transcripts (the "trash" view), most-recently-deleted +/// first. The contract mirrors `list_transcripts_paged` but filters +/// `deleted_at IS NOT NULL` so the regular history view and the trash view +/// are exact complements. +pub async fn list_trashed_transcripts( + pool: &SqlitePool, + limit: i64, + offset: i64, +) -> Result> { + let rows = sqlx::query( + "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags \ + FROM transcripts \ + WHERE deleted_at IS NOT NULL \ + ORDER BY deleted_at DESC LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(|source| Error::Query { + operation: "list_trashed_transcripts".into(), + source, + })?; + + Ok(rows.iter().map(transcript_row_from).collect()) +} + +/// Restore a soft-deleted transcript by clearing `deleted_at`. Idempotent: +/// restoring a live row is a no-op. Note that the audio file at +/// `audio_path` may already have been removed by `delete_transcript`'s +/// best-effort filesystem cleanup; restoring the row recovers the text and +/// metadata but the audio may still be missing. +pub async fn restore_transcript(pool: &SqlitePool, id: &str) -> Result<()> { + sqlx::query("UPDATE transcripts SET deleted_at = NULL WHERE id = ?") .bind(id) .execute(pool) .await .map_err(|source| Error::Query { - operation: "delete_transcript".into(), + operation: "restore_transcript".into(), source, })?; Ok(()) @@ -314,11 +495,15 @@ pub async fn search_transcripts( query: &str, limit: i64, ) -> Result> { + // The FTS triggers from migration v2 keep `transcripts_fts` in sync + // for every INSERT/UPDATE/DELETE — including the soft-delete UPDATE, + // which keeps the row in transcripts_fts. The `t.deleted_at IS NULL` + // filter on the JOIN keeps trashed rows out of search results. let rows = sqlx::query( "SELECT t.id, t.text, t.source, t.profile_id, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at, t.starred, t.manual_tags, t.template, t.language, t.segments_json, t.llm_tags \ FROM transcripts t \ JOIN transcripts_fts fts ON fts.rowid = t.rowid \ - WHERE transcripts_fts MATCH ? \ + WHERE transcripts_fts MATCH ? AND t.deleted_at IS NULL \ ORDER BY fts.rank LIMIT ?", ) .bind(query) @@ -905,6 +1090,63 @@ pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result> Ok(row.map(|r| r.get("value"))) } +/// One-shot key rename for settings rows carried over from the magnotia era +/// (`magnotia_preferences`, `magnotia_morning_triage_last_shown`, etc.) to +/// the lumotia naming convention. +/// +/// Idempotent. Returns `(renamed, orphans_deleted)`: +/// * `renamed` — rows where only the legacy (magnotia) key existed; the +/// row's key is updated to the lumotia equivalent. +/// * `orphans_deleted` — rows where BOTH keys existed; the new (lumotia) +/// row is authoritative and the legacy (magnotia) row is deleted to +/// avoid silent debt. +pub async fn migrate_legacy_setting_keys(pool: &SqlitePool) -> Result<(u64, u64)> { + let mut tx = pool.begin().await.map_err(|source| Error::Query { + operation: "migrate_legacy_setting_keys:begin".into(), + source, + })?; + + let renamed = sqlx::query( + "UPDATE settings \ + SET key = 'lumotia_' || substr(key, length('magnotia_') + 1) \ + WHERE key LIKE 'magnotia_%' \ + AND NOT EXISTS (\ + SELECT 1 FROM settings AS dst \ + WHERE dst.key = 'lumotia_' || substr(settings.key, length('magnotia_') + 1)\ + )", + ) + .execute(&mut *tx) + .await + .map_err(|source| Error::Query { + operation: "migrate_legacy_setting_keys:rename".into(), + source, + })? + .rows_affected(); + + let deleted = sqlx::query( + "DELETE FROM settings \ + WHERE key LIKE 'magnotia_%' \ + AND EXISTS (\ + SELECT 1 FROM settings AS dst \ + WHERE dst.key = 'lumotia_' || substr(settings.key, length('magnotia_') + 1)\ + )", + ) + .execute(&mut *tx) + .await + .map_err(|source| Error::Query { + operation: "migrate_legacy_setting_keys:delete_orphans".into(), + source, + })? + .rows_affected(); + + tx.commit().await.map_err(|source| Error::Query { + operation: "migrate_legacy_setting_keys:commit".into(), + source, + })?; + + Ok((renamed, deleted)) +} + // --- Row types --- #[derive(Debug, Clone)] @@ -944,7 +1186,7 @@ pub struct TranscriptRow { pub anti_hallucination: bool, pub created_at: String, // Task 2.5 — transcripts_meta (migration v5). Persists the UI metadata - // that previously lived in the removed localStorage `magnotia_history` cache. + // that previously lived in the removed localStorage `lumotia_history` cache. pub starred: bool, pub manual_tags: String, pub template: String, @@ -1384,7 +1626,7 @@ pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result Ok(row.get::("id")) } +// --- Onboarding events --- + +/// Row returned by [`list_onboarding_events`]. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct OnboardingEventRow { + pub id: i64, + pub event: String, + pub completed_at: i64, + pub version: String, + pub skipped: bool, + pub notes: Option, +} + +/// Row returned by [`list_lumotia_events`]. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct LumotiaEventRow { + pub id: i64, + pub kind: String, + pub occurred_at: i64, + pub payload: Option, +} + +/// Insert a single onboarding step event. +/// +/// `now` is a Unix timestamp (seconds) — the caller is responsible for +/// computing it so the helper stays testable without a clock dependency. +pub async fn insert_onboarding_event( + pool: &SqlitePool, + event: &str, + version: &str, + skipped: bool, + notes: Option<&str>, + now: i64, +) -> Result<()> { + sqlx::query( + "INSERT INTO onboarding_events (event, completed_at, version, skipped, notes) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(event) + .bind(now) + .bind(version) + .bind(skipped as i64) + .bind(notes) + .execute(pool) + .await + .map_err(|source| Error::Query { + operation: "insert_onboarding_event".into(), + source, + })?; + Ok(()) +} + +/// Return all onboarding events, oldest first. +pub async fn list_onboarding_events(pool: &SqlitePool) -> Result> { + let rows = sqlx::query( + "SELECT id, event, completed_at, version, skipped, notes + FROM onboarding_events + ORDER BY id ASC", + ) + .fetch_all(pool) + .await + .map_err(|source| Error::Query { + operation: "list_onboarding_events".into(), + source, + })?; + + Ok(rows + .into_iter() + .map(|r| OnboardingEventRow { + id: r.get("id"), + event: r.get("event"), + completed_at: r.get("completed_at"), + version: r.get("version"), + skipped: r.get::("skipped") != 0, + notes: r.get("notes"), + }) + .collect()) +} + +/// Returns `true` if the user has ever recorded a `completed` or `skipped` +/// onboarding event — i.e. they do not need to see onboarding again. +pub async fn has_completed_onboarding(pool: &SqlitePool) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM onboarding_events WHERE event IN ('completed', 'skipped')", + ) + .fetch_one(pool) + .await + .map_err(|source| Error::Query { + operation: "has_completed_onboarding".into(), + source, + })?; + Ok(count > 0) +} + +/// Insert a single opt-in activation log event. +/// +/// `now` is a Unix timestamp (seconds). +pub async fn insert_lumotia_event( + pool: &SqlitePool, + kind: &str, + payload: Option<&str>, + now: i64, +) -> Result<()> { + sqlx::query("INSERT INTO lumotia_events (kind, occurred_at, payload) VALUES (?, ?, ?)") + .bind(kind) + .bind(now) + .bind(payload) + .execute(pool) + .await + .map_err(|source| Error::Query { + operation: "insert_lumotia_event".into(), + source, + })?; + Ok(()) +} + +/// Return all lumotia events, oldest first. +pub async fn list_lumotia_events(pool: &SqlitePool) -> Result> { + let rows = sqlx::query( + "SELECT id, kind, occurred_at, payload + FROM lumotia_events + ORDER BY id ASC", + ) + .fetch_all(pool) + .await + .map_err(|source| Error::Query { + operation: "list_lumotia_events".into(), + source, + })?; + + Ok(rows + .into_iter() + .map(|r| LumotiaEventRow { + id: r.get("id"), + kind: r.get("kind"), + occurred_at: r.get("occurred_at"), + payload: r.get("payload"), + }) + .collect()) +} + +/// Delete all rows from `lumotia_events`. +pub async fn clear_lumotia_events(pool: &SqlitePool) -> Result<()> { + sqlx::query("DELETE FROM lumotia_events") + .execute(pool) + .await + .map_err(|source| Error::Query { + operation: "clear_lumotia_events".into(), + source, + })?; + Ok(()) +} + /// Fetch the most recent feedback rows for a given target type, scoped to /// the active profile. Used by the prompt builder to gather few-shot /// exemplars. Orders by `created_at DESC` so the most recent corrections @@ -2617,7 +3012,7 @@ mod tests { #[tokio::test] async fn init_readonly_rejects_writes_and_serves_reads() { - let dir = std::env::temp_dir().join(format!("magnotia-storage-ro-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("lumotia-storage-ro-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("ro.db"); let _ = std::fs::remove_file(&path); @@ -2669,7 +3064,7 @@ mod tests { #[tokio::test] async fn init_readonly_fails_when_db_missing() { let path = std::env::temp_dir().join(format!( - "magnotia-storage-ro-missing-{}.db", + "lumotia-storage-ro-missing-{}.db", std::process::id() )); let _ = std::fs::remove_file(&path); @@ -2715,4 +3110,425 @@ mod tests { .unwrap(); assert_eq!(remaining, vec!["recent".to_string()]); } + + #[tokio::test] + async fn migrate_legacy_setting_keys_renames_lone_magnotia_rows() { + let pool = test_pool().await; + set_setting(&pool, "magnotia_preferences", "blob-A") + .await + .unwrap(); + set_setting(&pool, "magnotia_morning_triage_last_shown", "2026-05-12") + .await + .unwrap(); + + let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap(); + + assert_eq!(renamed, 2); + assert_eq!(deleted, 0); + assert_eq!( + get_setting(&pool, "lumotia_preferences") + .await + .unwrap() + .as_deref(), + Some("blob-A") + ); + assert_eq!( + get_setting(&pool, "magnotia_preferences").await.unwrap(), + None + ); + assert_eq!( + get_setting(&pool, "lumotia_morning_triage_last_shown") + .await + .unwrap() + .as_deref(), + Some("2026-05-12") + ); + } + + #[tokio::test] + async fn migrate_legacy_setting_keys_deletes_orphans_when_both_present() { + let pool = test_pool().await; + set_setting(&pool, "magnotia_preferences", "legacy-blob") + .await + .unwrap(); + set_setting(&pool, "lumotia_preferences", "current-blob") + .await + .unwrap(); + + let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap(); + + assert_eq!(renamed, 0); + assert_eq!(deleted, 1); + assert_eq!( + get_setting(&pool, "lumotia_preferences") + .await + .unwrap() + .as_deref(), + Some("current-blob"), + "lumotia row preserved verbatim" + ); + assert_eq!( + get_setting(&pool, "magnotia_preferences").await.unwrap(), + None, + "orphan lumotia row deleted" + ); + } + + #[tokio::test] + async fn migrate_legacy_setting_keys_no_op_when_no_magnotia_rows() { + let pool = test_pool().await; + set_setting(&pool, "lumotia_preferences", "blob") + .await + .unwrap(); + + let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap(); + + assert_eq!(renamed, 0); + assert_eq!(deleted, 0); + } + + #[tokio::test] + async fn migrate_legacy_setting_keys_is_idempotent() { + let pool = test_pool().await; + set_setting(&pool, "magnotia_preferences", "blob") + .await + .unwrap(); + + let first = migrate_legacy_setting_keys(&pool).await.unwrap(); + let second = migrate_legacy_setting_keys(&pool).await.unwrap(); + + assert_eq!(first, (1, 0)); + assert_eq!(second, (0, 0)); + } + + fn minimal_transcript( + id: &'static str, + audio_path: Option<&'static str>, + ) -> InsertTranscriptParams<'static> { + InsertTranscriptParams { + id, + text: "soft-delete fixture", + source: "microphone", + profile_id: crate::DEFAULT_PROFILE_ID, + title: None, + audio_path, + duration: 1.0, + engine: None, + model_id: None, + inference_ms: None, + sample_rate: None, + audio_channels: None, + format_mode: None, + remove_fillers: false, + british_english: true, + anti_hallucination: false, + } + } + + #[tokio::test] + async fn delete_transcript_soft_deletes() { + // Rev-2 contract: delete_transcript flips `deleted_at`, does not + // remove the row. A direct SELECT bypassing the deleted_at filter + // must still find the row. + let pool = test_pool().await; + insert_transcript(&pool, &minimal_transcript("t-soft", None)) + .await + .unwrap(); + + delete_transcript(&pool, "t-soft").await.unwrap(); + + let deleted_at: Option = + sqlx::query_scalar("SELECT deleted_at FROM transcripts WHERE id = ?") + .bind("t-soft") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + deleted_at.is_some(), + "row should still exist with deleted_at set" + ); + } + + #[tokio::test] + async fn delete_transcript_removes_audio_file() { + // Rev-3 contract: best-effort fs::remove_file fires when the + // soft-delete actually flipped a row. A repeat delete is a + // no-op and does NOT fail when the file is already gone. + let pool = test_pool().await; + let tmp = std::env::temp_dir().join(format!("lumotia-test-{}.wav", std::process::id())); + std::fs::write(&tmp, b"fake wav").unwrap(); + let path_owned = tmp.to_string_lossy().to_string(); + let path_static: &'static str = Box::leak(path_owned.into_boxed_str()); + + insert_transcript(&pool, &minimal_transcript("t-audio", Some(path_static))) + .await + .unwrap(); + assert!(tmp.exists(), "fixture file should exist pre-delete"); + + delete_transcript(&pool, "t-audio").await.unwrap(); + assert!( + !tmp.exists(), + "audio file should be removed by delete_transcript" + ); + + // Repeat delete must not surface an error even though both the + // soft-delete UPDATE is a no-op AND the audio file is already gone. + delete_transcript(&pool, "t-audio").await.unwrap(); + } + + #[tokio::test] + async fn list_transcripts_excludes_soft_deleted() { + // Rev-2 list contract: soft-deleted rows are invisible to the + // regular list path; the trash view is the inverse. + let pool = test_pool().await; + insert_transcript(&pool, &minimal_transcript("t-live", None)) + .await + .unwrap(); + insert_transcript(&pool, &minimal_transcript("t-trashed", None)) + .await + .unwrap(); + + delete_transcript(&pool, "t-trashed").await.unwrap(); + + let live = list_transcripts(&pool, 100).await.unwrap(); + assert_eq!(live.len(), 1); + assert_eq!(live[0].id, "t-live"); + + let trashed = list_trashed_transcripts(&pool, 100, 0).await.unwrap(); + assert_eq!(trashed.len(), 1); + assert_eq!(trashed[0].id, "t-trashed"); + + let total = count_transcripts(&pool).await.unwrap(); + assert_eq!(total, 1, "count_transcripts excludes the trash"); + + // Restore brings the row back into the live list. + restore_transcript(&pool, "t-trashed").await.unwrap(); + let after_restore = list_transcripts(&pool, 100).await.unwrap(); + assert_eq!(after_restore.len(), 2); + } + + #[tokio::test] + async fn purge_deleted_transcripts_hard_deletes_old() { + // Retention contract: purge_deleted_transcripts hard-removes rows + // whose deleted_at is older than `older_than_days`. Newer trash + // rows are preserved. + let pool = test_pool().await; + insert_transcript(&pool, &minimal_transcript("t-old", None)) + .await + .unwrap(); + insert_transcript(&pool, &minimal_transcript("t-new", None)) + .await + .unwrap(); + delete_transcript(&pool, "t-old").await.unwrap(); + delete_transcript(&pool, "t-new").await.unwrap(); + + // Backdate t-old past the 30-day window. t-new keeps "now". + sqlx::query("UPDATE transcripts SET deleted_at = datetime('now', '-60 days') WHERE id = ?") + .bind("t-old") + .execute(&pool) + .await + .unwrap(); + + let purged = purge_deleted_transcripts(&pool, 30).await.unwrap(); + assert_eq!(purged, 1, "only t-old should be hard-deleted"); + + let old_exists: Option = + sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?") + .bind("t-old") + .fetch_optional(&pool) + .await + .unwrap(); + assert!(old_exists.is_none(), "t-old should be hard-gone"); + + let new_exists: Option = + sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?") + .bind("t-new") + .fetch_optional(&pool) + .await + .unwrap(); + assert!( + new_exists.is_some(), + "t-new still inside the retention window" + ); + } + + /// Phase B.4 audit regression (2026-05-14). Asserts the + /// `DELETE … RETURNING` form correctly couples row-removal with the + /// audio-cleanup loop: only rows the DELETE actually affected get + /// their audio file removed, and rows outside the retention window + /// are untouched. + /// + /// The race we fixed (restore between SELECT and DELETE in the old + /// two-statement form) requires fault injection between the two + /// statements to exercise deterministically. The atomic single- + /// statement form makes that race structurally impossible. We test + /// the structural property: an in-retention trashed row with its + /// audio file on disk survives purge with the audio intact, while a + /// past-retention trashed row is hard-deleted with audio removed. + #[tokio::test] + async fn purge_audio_cleanup_only_fires_for_hard_deleted_rows() { + let pool = test_pool().await; + + let tmpdir = tempfile::tempdir().expect("tmpdir"); + let old_audio = tmpdir.path().join("old.wav"); + let recent_audio = tmpdir.path().join("recent.wav"); + std::fs::write(&old_audio, b"old wav").unwrap(); + std::fs::write(&recent_audio, b"recent wav").unwrap(); + + let old_path_static: &'static str = + Box::leak(old_audio.to_string_lossy().to_string().into_boxed_str()); + let recent_path_static: &'static str = + Box::leak(recent_audio.to_string_lossy().to_string().into_boxed_str()); + + insert_transcript( + &pool, + &minimal_transcript("t-purge-old", Some(old_path_static)), + ) + .await + .unwrap(); + insert_transcript( + &pool, + &minimal_transcript("t-purge-recent", Some(recent_path_static)), + ) + .await + .unwrap(); + + // Manually mark both trashed without going through delete_transcript + // (which would best-effort-remove the audio files itself). Old row + // is backdated past the 30-day retention; recent row is fresh. + sqlx::query("UPDATE transcripts SET deleted_at = datetime('now', '-60 days') WHERE id = ?") + .bind("t-purge-old") + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE transcripts SET deleted_at = datetime('now') WHERE id = ?") + .bind("t-purge-recent") + .execute(&pool) + .await + .unwrap(); + + let purged = purge_deleted_transcripts(&pool, 30).await.unwrap(); + assert_eq!(purged, 1, "only t-purge-old is past retention"); + + let old_exists: Option = + sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?") + .bind("t-purge-old") + .fetch_optional(&pool) + .await + .unwrap(); + assert!(old_exists.is_none(), "t-purge-old must be hard-deleted"); + assert!( + !old_audio.exists(), + "audio for hard-deleted row must be removed by purge" + ); + + let recent_exists: Option = + sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?") + .bind("t-purge-recent") + .fetch_optional(&pool) + .await + .unwrap(); + assert!( + recent_exists.is_some(), + "t-purge-recent within retention must survive" + ); + assert!( + recent_audio.exists(), + "audio for surviving in-retention row must NOT be removed by purge" + ); + } + + // --- onboarding_events tests --- + + #[tokio::test] + async fn onboarding_insert_and_list_roundtrip() { + let pool = test_pool().await; + insert_onboarding_event(&pool, "started", "0.1.0", false, None, 1_000_000) + .await + .unwrap(); + insert_onboarding_event( + &pool, + "recorded_first", + "0.1.0", + false, + Some("took 45s"), + 1_000_060, + ) + .await + .unwrap(); + + let rows = list_onboarding_events(&pool).await.unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].event, "started"); + assert_eq!(rows[0].version, "0.1.0"); + assert!(!rows[0].skipped); + assert!(rows[0].notes.is_none()); + assert_eq!(rows[1].event, "recorded_first"); + assert_eq!(rows[1].notes.as_deref(), Some("took 45s")); + } + + #[tokio::test] + async fn has_completed_onboarding_no_events() { + let pool = test_pool().await; + assert!(!has_completed_onboarding(&pool).await.unwrap()); + } + + #[tokio::test] + async fn has_completed_onboarding_only_started() { + let pool = test_pool().await; + insert_onboarding_event(&pool, "started", "0.1.0", false, None, 1_000_000) + .await + .unwrap(); + assert!(!has_completed_onboarding(&pool).await.unwrap()); + } + + #[tokio::test] + async fn has_completed_onboarding_with_completed_event() { + let pool = test_pool().await; + insert_onboarding_event(&pool, "started", "0.1.0", false, None, 1_000_000) + .await + .unwrap(); + insert_onboarding_event(&pool, "completed", "0.1.0", false, None, 1_000_120) + .await + .unwrap(); + assert!(has_completed_onboarding(&pool).await.unwrap()); + } + + #[tokio::test] + async fn has_completed_onboarding_with_skipped_event() { + let pool = test_pool().await; + insert_onboarding_event(&pool, "skipped", "0.1.0", true, None, 1_000_005) + .await + .unwrap(); + assert!(has_completed_onboarding(&pool).await.unwrap()); + } + + // --- lumotia_events tests --- + + #[tokio::test] + async fn lumotia_event_insert_list_clear_roundtrip() { + let pool = test_pool().await; + insert_lumotia_event(&pool, "app_launched", None, 1_000_000) + .await + .unwrap(); + insert_lumotia_event( + &pool, + "recording_started", + Some(r#"{"profile":"default"}"#), + 1_000_010, + ) + .await + .unwrap(); + + let rows = list_lumotia_events(&pool).await.unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].kind, "app_launched"); + assert!(rows[0].payload.is_none()); + assert_eq!(rows[1].kind, "recording_started"); + assert_eq!(rows[1].payload.as_deref(), Some(r#"{"profile":"default"}"#)); + + clear_lumotia_events(&pool).await.unwrap(); + let rows_after = list_lumotia_events(&pool).await.unwrap(); + assert!(rows_after.is_empty()); + } } diff --git a/crates/storage/src/error.rs b/crates/storage/src/error.rs index f566ee6..9711bd3 100644 --- a/crates/storage/src/error.rs +++ b/crates/storage/src/error.rs @@ -1,8 +1,8 @@ //! Storage-local typed error. //! //! Backend code that wants to branch on storage failure modes pattern-matches -//! on [`Error`] directly. The crate boundary into [`magnotia_core::error::MagnotiaError`] -//! flattens this into a [`MagnotiaError::Storage`] variant carrying a serde-friendly +//! on [`Error`] directly. The crate boundary into [`lumotia_core::error::Error`] +//! flattens this into a [`Error::Storage`] variant carrying a serde-friendly //! `kind`, an `operation` label, and the Display output as `detail` — so the //! frontend (which still receives stringified errors from Tauri commands today) //! keeps working, and Area E can later expose the typed `kind` to the FE without @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::path::PathBuf; -use magnotia_core::error::{MagnotiaError, StorageKind}; +use lumotia_core::error::{Error as CoreError, StorageKind}; /// Kinds of database-open operation that can fail before the pool is ready. #[derive(Debug, Clone, Copy)] @@ -145,7 +145,7 @@ pub enum Error { } impl Error { - /// Discriminator for the boundary conversion into [`MagnotiaError`]. + /// Discriminator for the boundary conversion into [`Error`]. pub fn kind(&self) -> StorageKind { match self { Error::DatabaseOpen { .. } => StorageKind::DatabaseOpen, @@ -157,7 +157,7 @@ impl Error { } } - /// Operation label that flows into the [`MagnotiaError::Storage`] boundary + /// Operation label that flows into the [`Error::Storage`] boundary /// shape. For per-query failures this is the caller-provided label; /// for the other variants it's the variant's intrinsic label. pub fn operation_label(&self) -> Cow<'static, str> { @@ -175,14 +175,14 @@ impl Error { } /// Boundary conversion — flattens the typed error into the wire-friendly -/// [`MagnotiaError::Storage`] variant. Lives in the storage crate (not in core) +/// [`CoreError::Storage`] variant. Lives in the storage crate (not in core) /// to avoid a `core -> storage` dependency cycle. -impl From for MagnotiaError { +impl From for CoreError { fn from(error: Error) -> Self { let kind = error.kind(); let operation = error.operation_label().into_owned(); let detail = error.to_string(); - MagnotiaError::Storage { + CoreError::Storage { kind, operation, detail, diff --git a/crates/storage/src/file_storage.rs b/crates/storage/src/file_storage.rs index 3238ac9..bb4cf9b 100644 --- a/crates/storage/src/file_storage.rs +++ b/crates/storage/src/file_storage.rs @@ -1,28 +1,36 @@ use std::path::PathBuf; pub fn app_data_dir() -> PathBuf { - magnotia_core::paths::app_paths().app_data_dir() + lumotia_core::paths::app_paths().app_data_dir() } /// Path to the SQLite database file. pub fn database_path() -> PathBuf { - magnotia_core::paths::app_paths().database_path() + lumotia_core::paths::app_paths().database_path() } /// Directory for saved audio recordings. pub fn recordings_dir() -> PathBuf { - magnotia_core::paths::app_paths().recordings_dir() + lumotia_core::paths::app_paths().recordings_dir() } /// Directory for crash dumps written by the Rust panic hook. /// Each crash is a single text file: `-.crash`. /// Used by the diagnostic-report bundler in Settings → About. pub fn crashes_dir() -> PathBuf { - magnotia_core::paths::app_paths().crashes_dir() + lumotia_core::paths::app_paths().crashes_dir() } -/// Directory for the rolling Rust log file (magnotia.log + rotated magnotia.log.1, etc). -/// Subscribers configured in src-tauri/src/lib.rs at startup. +/// Directory for the rolling Rust log file. +/// +/// The base filename is `lumotia.log`; daily rotation produces dated +/// siblings (e.g. `lumotia.log.2026-05-12`). The appender keeps the +/// 7 most-recent days and prunes older files on rotation. +/// +/// Subscriber and rotation policy are configured by `init_tracing` in +/// `src-tauri/src/lib.rs` at startup; the diagnostic-report bundler in +/// `src-tauri/src/commands/diagnostics.rs` attaches the live file from +/// this directory to user-submitted crash reports. pub fn logs_dir() -> PathBuf { - magnotia_core::paths::app_paths().logs_dir() + lumotia_core::paths::app_paths().logs_dir() } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 5320b73..64090db 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -10,17 +10,20 @@ pub use error::{Entity, Error, MigrationStep, OpenOp, Result}; pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001"; pub use database::{ - add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts, - create_profile, delete_implementation_rule, delete_profile, delete_profile_term, delete_task, - delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id, - get_transcript, init, init_readonly, insert_implementation_rule, insert_subtask, insert_task, - insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms, - list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks, - list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired, - prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled, - set_setting, set_task_energy, uncomplete_task, update_profile, update_task, update_transcript, + add_profile_term, clear_lumotia_events, complete_subtask_and_check_parent, complete_task, + count_transcripts, create_profile, delete_implementation_rule, delete_profile, + delete_profile_term, delete_task, delete_transcript, get_implementation_rule, get_profile, + get_setting, get_task_by_id, get_transcript, has_completed_onboarding, init, init_readonly, + insert_implementation_rule, insert_lumotia_event, insert_onboarding_event, insert_subtask, + insert_task, insert_transcript, list_feedback_examples, list_implementation_rules, + list_lumotia_events, list_onboarding_events, list_profile_terms, list_profiles, + list_recent_completions, list_recent_errors, list_subtasks, list_tasks, list_transcripts, + list_transcripts_paged, list_trashed_transcripts, log_error, mark_implementation_rule_fired, + migrate_legacy_setting_keys, prune_error_log, purge_deleted_transcripts, record_feedback, + restore_transcript, search_transcripts, set_implementation_rule_enabled, set_setting, + set_task_energy, uncomplete_task, update_profile, update_task, update_transcript, update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType, - ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow, - RecordFeedbackParams, TaskRow, TranscriptRow, + ImplementationRuleRow, InsertTranscriptParams, LumotiaEventRow, OnboardingEventRow, ProfileRow, + ProfileTermRow, RecordFeedbackParams, TaskRow, TranscriptRow, }; pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir}; diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index c830bf4..50416a4 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -477,6 +477,62 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ ON transcripts(profile_id, created_at DESC); "#, ), + ( + 16, + "transcripts: soft-delete column for trash + audio cleanup (Rev-2, Rev-3)", + r#" + -- Atomiser fix 2026-05-12 — Rev-2 (hard-DELETE w/ no trash) and + -- Rev-3 (orphan WAV files on transcript delete) both fanned out + -- from the same DELETE-based delete path. The new contract: + -- + -- * `delete_transcript` UPDATEs deleted_at = datetime('now') + -- and best-effort removes the audio file at audio_path. + -- The DB row is preserved so the user can restore. + -- * `list_transcripts*`, `search_transcripts`, `count_transcripts` + -- filter `deleted_at IS NULL`. + -- * `purge_deleted_transcripts(older_than_days)` does the + -- actual hard DELETE on rows past the retention window, + -- scheduled once at startup. FK CASCADE on segments still + -- fires at purge time. + -- + -- Pre-existing rows default to NULL (not deleted). The column + -- is plain TEXT (ISO-8601 datetime), nullable, no CHECK — we + -- only ever compare against datetime('now', '-N days') and + -- IS NULL / IS NOT NULL. + ALTER TABLE transcripts ADD COLUMN deleted_at TEXT; + + -- Partial index over the trash so the purge query stays cheap + -- even on databases with months of soft-deleted rows. + CREATE INDEX IF NOT EXISTS idx_transcripts_deleted_at + ON transcripts(deleted_at) WHERE deleted_at IS NOT NULL; + "#, + ), + ( + 17, + "onboarding_events + lumotia_events tables", + r#" + -- onboarding_events: gates first-run, supplies time-to-first-capture metric + CREATE TABLE IF NOT EXISTS onboarding_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event TEXT NOT NULL, + completed_at INTEGER NOT NULL, + version TEXT NOT NULL, + skipped INTEGER NOT NULL DEFAULT 0, + notes TEXT + ); + CREATE INDEX IF NOT EXISTS idx_onboarding_events_event ON onboarding_events(event); + + -- lumotia_events: opt-in local activation log + CREATE TABLE IF NOT EXISTS lumotia_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + occurred_at INTEGER NOT NULL, + payload TEXT + ); + CREATE INDEX IF NOT EXISTS idx_lumotia_events_kind ON lumotia_events(kind); + CREATE INDEX IF NOT EXISTS idx_lumotia_events_occurred ON lumotia_events(occurred_at); + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -570,7 +626,12 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str) for (version, description, sql) in migrations { if *version > current { - log::info!("Running migration {}: {}", version, description); + tracing::info!( + target: "lumotia_storage", + version, + description, + "running migration", + ); let mut tx = pool.begin().await.map_err(|source| Error::Migration { version: Some(*version), @@ -606,7 +667,11 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str) source, })?; - log::info!("Migration {} complete", version); + tracing::info!( + target: "lumotia_storage", + version, + "migration complete", + ); } } @@ -642,7 +707,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 15); + assert_eq!(count, 17); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -661,7 +726,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 15); + assert_eq!(count, 17); } #[tokio::test] @@ -963,7 +1028,7 @@ mod tests { // dictionary.id is INTEGER PK AUTOINCREMENT (see v2); let SQLite assign rowids. sqlx::query( "INSERT INTO dictionary (term, note, created_at) VALUES \ - ('Magnotia', '', datetime('now')), \ + ('Lumotia', '', datetime('now')), \ ('CORBEL', 'brand', datetime('now')), \ ('Wren', '', datetime('now'))", ) @@ -1199,4 +1264,144 @@ mod tests { "planner should use the composite index, got plan: {plan:?}", ); } + + #[tokio::test] + async fn migration_v17_creates_onboarding_and_lumotia_events_tables() { + // v0.1 onboarding-event tracking and opt-in local activation log. + // Verify both tables exist with the expected columns after running + // all migrations. Uses PRAGMA table_info to inspect column presence. + let pool = fk_test_pool().await; + run_migrations(&pool).await.expect("migrate"); + + // --- onboarding_events --- + let info = sqlx::query("PRAGMA table_info(onboarding_events)") + .fetch_all(&pool) + .await + .expect("pragma onboarding_events"); + assert!( + !info.is_empty(), + "onboarding_events table must exist after v17" + ); + let names: Vec = info.iter().map(|r| r.get::("name")).collect(); + for col in ["id", "event", "completed_at", "version", "skipped", "notes"] { + assert!( + names.contains(&col.to_string()), + "onboarding_events must have column {col}; got {names:?}" + ); + } + + // Index on onboarding_events(event) must exist. + let idx_names: Vec = sqlx::query_scalar( + "SELECT name FROM sqlite_master \ + WHERE type = 'index' AND tbl_name = 'onboarding_events'", + ) + .fetch_all(&pool) + .await + .expect("read onboarding_events indexes"); + assert!( + idx_names.iter().any(|n| n == "idx_onboarding_events_event"), + "expected idx_onboarding_events_event, got {idx_names:?}", + ); + + // --- lumotia_events --- + let info2 = sqlx::query("PRAGMA table_info(lumotia_events)") + .fetch_all(&pool) + .await + .expect("pragma lumotia_events"); + assert!( + !info2.is_empty(), + "lumotia_events table must exist after v17" + ); + let names2: Vec = info2.iter().map(|r| r.get::("name")).collect(); + for col in ["id", "kind", "occurred_at", "payload"] { + assert!( + names2.contains(&col.to_string()), + "lumotia_events must have column {col}; got {names2:?}" + ); + } + + // Both indexes on lumotia_events must exist. + let idx_names2: Vec = sqlx::query_scalar( + "SELECT name FROM sqlite_master \ + WHERE type = 'index' AND tbl_name = 'lumotia_events'", + ) + .fetch_all(&pool) + .await + .expect("read lumotia_events indexes"); + for idx in ["idx_lumotia_events_kind", "idx_lumotia_events_occurred"] { + assert!( + idx_names2.iter().any(|n| n == idx), + "expected index {idx}, got {idx_names2:?}", + ); + } + } + + #[tokio::test] + async fn migration_v16_adds_deleted_at_column_and_index() { + // Rev-2 / Rev-3 atomiser fix (2026-05-12). Verify the soft-delete + // column is present, nullable, defaulting to NULL on pre-existing + // rows so the migration doesn't accidentally mark old transcripts + // as deleted. + let pool = fk_test_pool().await; + run_migrations_up_to(&pool, 15) + .await + .expect("migrate to v15"); + + // Seed a pre-v16 row to verify backfill preserves NULL. + sqlx::query( + "INSERT INTO transcripts ( + id, text, source, profile_id, title, audio_path, duration, + engine, model_id, inference_ms, sample_rate, audio_channels, + format_mode, remove_fillers, british_english, anti_hallucination, + created_at, starred, manual_tags, template, language, + segments_json, llm_tags + ) VALUES ( + 'pre-v16', 'pre-existing body', 'microphone', ?, 'Pre-V16', + NULL, 1.0, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0, 0, + datetime('now'), 0, '', '', '', '', '' + )", + ) + .bind(crate::DEFAULT_PROFILE_ID) + .execute(&pool) + .await + .expect("seed pre-v16 row"); + + run_migrations(&pool).await.expect("migrate to v16"); + + let info = sqlx::query("PRAGMA table_info(transcripts)") + .fetch_all(&pool) + .await + .expect("pragma"); + let names: Vec = info.iter().map(|r| r.get::("name")).collect(); + assert!( + names.contains(&"deleted_at".to_string()), + "transcripts must have deleted_at after v16; got {names:?}", + ); + + // Pre-existing rows must default to NULL — emphatically NOT + // pre-soft-deleted. + let deleted_at: Option = + sqlx::query_scalar("SELECT deleted_at FROM transcripts WHERE id = 'pre-v16'") + .fetch_one(&pool) + .await + .expect("read deleted_at"); + assert!( + deleted_at.is_none(), + "pre-v16 rows must have deleted_at = NULL after migration, got {deleted_at:?}", + ); + + // Partial index exists. + let index_names: Vec = sqlx::query_scalar( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'transcripts'", + ) + .fetch_all(&pool) + .await + .expect("read indexes"); + assert!( + index_names + .iter() + .any(|n| n == "idx_transcripts_deleted_at"), + "expected idx_transcripts_deleted_at, got {index_names:?}", + ); + } } diff --git a/crates/storage/tests/legacy_db_migration.rs b/crates/storage/tests/legacy_db_migration.rs new file mode 100644 index 0000000..750c3ce --- /dev/null +++ b/crates/storage/tests/legacy_db_migration.rs @@ -0,0 +1,282 @@ +//! End-to-end migration test for the Magnotia -> Lumotia rebrand. +//! +//! The in-crate unit tests in `crates/core/src/paths.rs` prove that the +//! migration copies bytes and renames files correctly, but they don't +//! verify that the resulting `lumotia.db` is *openable* by +//! `lumotia-storage`. A byte-perfect copy with a torn SQLite header, +//! a stale WAL pointer, or a row count off by one would still pass +//! those tests. This integration test closes that gap by: +//! +//! 1. Seeding a real on-disk `magnotia/magnotia.db` via the public +//! `lumotia_storage::init` API (which runs every schema migration +//! head-to-tail). A real transcript row is inserted via +//! `insert_transcript`, then the pool is dropped to flush + close. +//! 2. Running the rebrand migration via +//! `lumotia_core::paths::migrate_legacy_data_dir_with_pairs` with +//! the synthesised (legacy, target) pair. +//! 3. Re-opening the migrated `lumotia/lumotia.db` via `init` +//! (which re-runs migrations — they must be no-ops against the +//! already-migrated schema) and querying for the inserted +//! transcript by id. +//! +//! If any of the three steps fails — rename, reopen, or query — the +//! migration is unsafe to ship even though the unit tests pass. + +use std::path::Path; + +use lumotia_core::paths::{migrate_legacy_data_dir_with_pairs, MigrationStatus}; +use lumotia_storage::{ + get_transcript, init, insert_transcript, list_transcripts, InsertTranscriptParams, + DEFAULT_PROFILE_ID, +}; +use tempfile::TempDir; + +const SEEDED_TRANSCRIPT_ID: &str = "t-rebrand-survivor"; +const SEEDED_TRANSCRIPT_TEXT: &str = + "Migration sentinel row. If this read fails, the rebrand orphaned user data."; +const SEEDED_TRANSCRIPT_TITLE: &str = "rebrand-survivor"; + +/// Drop the pool and yield long enough for sqlx's background reaper to +/// close its connections. The integration test re-opens the migrated DB +/// immediately afterwards, so any sqlx-held file descriptor on Windows +/// would block the rename. tokio's `yield_now` is a single scheduler +/// hop, not a sleep — enough to flush the pool's tokio task queue. +async fn drop_and_yield(pool: sqlx::SqlitePool) { + pool.close().await; + tokio::task::yield_now().await; +} + +/// Build the canonical seed-row params. Centralised so the assertions +/// in each test can reference the same expected values without drift. +fn seed_params() -> InsertTranscriptParams<'static> { + InsertTranscriptParams { + id: SEEDED_TRANSCRIPT_ID, + text: SEEDED_TRANSCRIPT_TEXT, + source: "microphone", + profile_id: DEFAULT_PROFILE_ID, + title: Some(SEEDED_TRANSCRIPT_TITLE), + audio_path: None, + duration: 2.5, + engine: Some("whisper"), + model_id: Some("whisper-tiny-en"), + inference_ms: Some(420), + sample_rate: Some(16_000), + audio_channels: Some(1), + format_mode: Some("plain"), + remove_fillers: false, + british_english: true, + anti_hallucination: false, + } +} + +/// Seed `/magnotia.db` with the live schema head and one +/// transcript row. Returns nothing; the asserts live in the test body. +async fn seed_legacy_db(legacy_dir: &Path) { + std::fs::create_dir_all(legacy_dir).expect("create legacy dir"); + let legacy_db = legacy_dir.join("magnotia.db"); + + let pool = init(&legacy_db).await.expect("init legacy magnotia.db"); + insert_transcript(&pool, &seed_params()) + .await + .expect("insert seed transcript into legacy db"); + drop_and_yield(pool).await; + + assert!( + legacy_db.exists(), + "legacy magnotia.db should be on disk after pool drop" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn legacy_db_survives_rebrand_and_remains_queryable() { + let tmp = TempDir::new().expect("tempdir"); + let legacy = tmp.path().join("magnotia"); + let target = tmp.path().join("lumotia"); + + seed_legacy_db(&legacy).await; + + // Bonus: drop a non-DB file inside the legacy tree to confirm the + // migration sweeps the whole directory, not just the .db file. + let companion = legacy + .join("recordings") + .join("2026-05-13") + .join("clip.wav"); + std::fs::create_dir_all(companion.parent().unwrap()).expect("nested dir"); + std::fs::write(&companion, b"fake-wav-bytes").expect("write companion file"); + + // Migrate. Blocking I/O is fine inside the multi-thread flavour. + let statuses = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())]) + .expect("migration must not error"); + + assert_eq!(statuses.len(), 1, "single pair must yield single status"); + match &statuses[0] { + MigrationStatus::Migrated { + from, + to, + renamed_db, + } => { + assert_eq!(from, &legacy); + assert_eq!(to, &target); + assert!(renamed_db, "magnotia.db must have been renamed"); + } + other => panic!("expected Migrated, got {other:?}"), + } + + assert!(!legacy.exists(), "legacy dir should be gone after rename"); + assert!(target.exists(), "target dir should exist after rename"); + let migrated_db = target.join("lumotia.db"); + assert!(migrated_db.exists(), "lumotia.db must be at new path"); + assert!( + !target.join("magnotia.db").exists(), + "old db filename must not survive at new path" + ); + let migrated_companion = target + .join("recordings") + .join("2026-05-13") + .join("clip.wav"); + assert!( + migrated_companion.exists(), + "non-DB files inside legacy must be carried along" + ); + assert_eq!( + std::fs::read(&migrated_companion).expect("read migrated companion"), + b"fake-wav-bytes", + "non-DB file contents must survive verbatim" + ); + + // Re-open via the same public API a fresh app boot would use. This + // also re-runs `run_migrations`, which must be idempotent against + // the already-migrated schema. + let pool = init(&migrated_db) + .await + .expect("init migrated lumotia.db must succeed"); + + let found = get_transcript(&pool, SEEDED_TRANSCRIPT_ID) + .await + .expect("get_transcript must not error against migrated db") + .expect("seeded transcript must survive the migration"); + + assert_eq!(found.id, SEEDED_TRANSCRIPT_ID); + assert_eq!(found.text, SEEDED_TRANSCRIPT_TEXT); + assert_eq!(found.title.as_deref(), Some(SEEDED_TRANSCRIPT_TITLE)); + + // And the list view sees exactly one row, ruling out duplicates or + // schema-rebuild-from-empty (which would yield zero). + let listed = list_transcripts(&pool, 100) + .await + .expect("list_transcripts must not error against migrated db"); + assert_eq!( + listed.len(), + 1, + "exactly one transcript should be visible post-migration" + ); + assert_eq!(listed[0].id, SEEDED_TRANSCRIPT_ID); + + drop_and_yield(pool).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn rerun_after_migration_is_idempotent_and_preserves_user_state() { + // First boot: migrate. Second boot: legacy preserved? Per the design + // the legacy DOES NOT preserve (rename moves it; see paths.rs + // rename_or_copy_tree). So the second run sees no legacy and yields + // NoLegacyFound. This test exists to nail down that contract — if a + // future change starts copying-rather-than-renaming, the test will + // catch the subsequent loss of idempotency. + let tmp = TempDir::new().expect("tempdir"); + let legacy = tmp.path().join("magnotia"); + let target = tmp.path().join("lumotia"); + + seed_legacy_db(&legacy).await; + + let first = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())]) + .expect("first migration"); + assert!(matches!(first[0], MigrationStatus::Migrated { .. })); + + // User immediately starts using the app: writes new data to the + // migrated DB. The follow-up reboot must NOT clobber this. + let pool = init(&target.join("lumotia.db")) + .await + .expect("post-migrate init"); + insert_transcript( + &pool, + &InsertTranscriptParams { + id: "t-post-migration", + text: "Written after the rebrand boot.", + ..seed_params() + }, + ) + .await + .expect("insert new row post-migration"); + drop_and_yield(pool).await; + + // Second boot: no legacy on disk, nothing to do. Note empty input + // yields a single NoLegacyFound entry by contract. + let second = migrate_legacy_data_dir_with_pairs(Vec::new()).expect("second migration"); + assert_eq!(second, vec![MigrationStatus::NoLegacyFound]); + + // Open and confirm both rows present + user's post-migration write + // intact. + let pool = init(&target.join("lumotia.db")) + .await + .expect("reopen after second boot"); + let listed = list_transcripts(&pool, 100).await.expect("list"); + let ids: Vec<_> = listed.iter().map(|r| r.id.as_str()).collect(); + assert!( + ids.contains(&SEEDED_TRANSCRIPT_ID), + "pre-migration row should survive second boot: {ids:?}" + ); + assert!( + ids.contains(&"t-post-migration"), + "post-migration row should survive second boot: {ids:?}" + ); + drop_and_yield(pool).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn both_paths_present_refuses_to_overwrite_user_data() { + // Reproduces the "stale legacy + freshly-installed lumotia" scenario: + // user reinstalls after deleting their data dir manually, or runs an + // older + newer build side-by-side. The migration must NOT clobber + // the lumotia-side DB even if the legacy contains a richer one — + // user authority on what's authoritative. + let tmp = TempDir::new().expect("tempdir"); + let legacy = tmp.path().join("magnotia"); + let target = tmp.path().join("lumotia"); + + // Both DBs exist; the legacy has the seeded row, the target is + // freshly initialised with no rows. + seed_legacy_db(&legacy).await; + let pool = init(&target.join("lumotia.db")).await.expect("seed target"); + drop_and_yield(pool).await; + + let statuses = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())]) + .expect("migration must not error in both-exist case"); + + assert_eq!(statuses.len(), 1); + assert_eq!( + statuses[0], + MigrationStatus::TargetAlreadyExists { + target: target.clone() + } + ); + + // Critical: target's empty DB must be untouched. + let pool = init(&target.join("lumotia.db")) + .await + .expect("reopen target post-decision"); + let listed = list_transcripts(&pool, 100).await.expect("list"); + assert_eq!( + listed.len(), + 0, + "target's user-installed empty DB must not have been merged with legacy" + ); + drop_and_yield(pool).await; + + // And the legacy DB must still be on disk for manual recovery — + // we don't quietly delete user data the migration refused to copy. + assert!( + legacy.join("magnotia.db").exists(), + "legacy DB must be preserved as a backup when target exists" + ); +} diff --git a/crates/transcription/Cargo.toml b/crates/transcription/Cargo.toml index 46ad464..3a094dd 100644 --- a/crates/transcription/Cargo.toml +++ b/crates/transcription/Cargo.toml @@ -1,8 +1,10 @@ [package] -name = "magnotia-transcription" -version = "0.1.0" -edition = "2021" -description = "Speech-to-text engine wrappers, model management, and inference concurrency for Magnotia" +name = "lumotia-transcription" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +description = "Speech-to-text engine wrappers, model management, and inference concurrency for Lumotia" build = "build.rs" [features] @@ -15,18 +17,18 @@ build = "build.rs" # `whisper-vulkan` is a separate feature so a non-Vulkan target (Android # without GPU drivers, a CPU-only Windows build) can pull in whisper-rs # but skip the Vulkan backend. Build CPU-only with: -# cargo build -p magnotia-transcription --no-default-features --features whisper +# cargo build -p lumotia-transcription --no-default-features --features whisper default = ["whisper", "whisper-vulkan"] whisper = ["dep:whisper-rs"] whisper-vulkan = ["whisper-rs?/vulkan"] [dependencies] -magnotia-core = { path = "../core" } +lumotia-core = { path = "../core" } # TranscriptionProvider async trait + EngineProfile + ProviderId. The # trait lives in cloud-providers so an OEM licensee can implement it # without depending on transcription internals. -magnotia-cloud-providers = { path = "../cloud-providers" } +lumotia-cloud-providers = { path = "../cloud-providers" } # Async-trait for the LocalProviderAdapter impl. async-trait = "0.1" @@ -57,12 +59,12 @@ thiserror = "2" tracing = "0.1" [dev-dependencies] -# TcpListener fixture for the download resume tests (mirrors magnotia-llm). +# TcpListener fixture for the download resume tests (mirrors lumotia-llm). # `macros` and `rt-multi-thread` enable #[tokio::test] and the multi-threaded # scheduler used by the orchestrator tests. tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "net", "io-util", "macros"] } tempfile = "3" # Test-only — used by tests/thread_sweep.rs to label physical vs logical # core counts in the scaling table. Production code uses the -# `magnotia_core::constants::inference_thread_count` helper instead. +# `lumotia_core::constants::inference_thread_count` helper instead. num_cpus = "1" diff --git a/crates/transcription/build.rs b/crates/transcription/build.rs index e821f25..9b69d51 100644 --- a/crates/transcription/build.rs +++ b/crates/transcription/build.rs @@ -11,7 +11,7 @@ //! workspace ever pulls `tokenizers` into the dependency graph on a //! Windows target. If we ever legitimately need it we can reintroduce //! it via a sidecar (isolated process, separate CRT) rather than -//! linking it into `magnotia_lib`. +//! linking it into `lumotia_lib`. //! //! The check is advisory on non-Windows targets — it still prints a //! cargo:warning if `tokenizers` appears, so the Windows failure isn't @@ -56,7 +56,7 @@ fn main() { if target_os == "windows" { panic!( - "magnotia-transcription: the `tokenizers` crate appears in Cargo.lock and this is a \ + "lumotia-transcription: the `tokenizers` crate appears in Cargo.lock and this is a \ Windows build. Linking `whisper-rs-sys` + `tokenizers` in the same binary has \ been a persistent MSVC C-runtime conflict (see Whispering v7.11.0). Route any \ tokenizer usage through an out-of-process sidecar instead, or gate it off for \ @@ -65,7 +65,7 @@ fn main() { } println!( - "cargo:warning=magnotia-transcription: `tokenizers` crate is in the dependency graph. \ + "cargo:warning=lumotia-transcription: `tokenizers` crate is in the dependency graph. \ This build is non-Windows so the link will succeed, but Windows builds will panic \ at build time per docs/whisper-ecosystem/brief.md item #6. Isolate tokenizer usage \ in a sidecar before a Windows ship." diff --git a/crates/transcription/src/concurrency.rs b/crates/transcription/src/concurrency.rs index 0c4c1d2..2b56673 100644 --- a/crates/transcription/src/concurrency.rs +++ b/crates/transcription/src/concurrency.rs @@ -1,7 +1,7 @@ use std::sync::Arc; -use magnotia_core::error::{MagnotiaError, Result}; -use magnotia_core::types::{AudioSamples, TranscriptionOptions}; +use lumotia_core::error::{Error, Result}; +use lumotia_core::types::{AudioSamples, TranscriptionOptions}; use crate::local_engine::{LocalEngine, TimedTranscript}; @@ -14,5 +14,5 @@ pub async fn run_inference( ) -> Result { tokio::task::spawn_blocking(move || engine.transcribe_sync(&audio, &options)) .await - .map_err(|e| MagnotiaError::TranscriptionFailed(format!("Task join error: {e}")))? + .map_err(|e| Error::TranscriptionFailed(format!("Task join error: {e}")))? } diff --git a/crates/transcription/src/lib.rs b/crates/transcription/src/lib.rs index d4f809a..cbbf416 100644 --- a/crates/transcription/src/lib.rs +++ b/crates/transcription/src/lib.rs @@ -23,11 +23,11 @@ pub use transcribe_rs::SpeechModel; pub use transcriber::{Transcriber, TranscriberCapabilities}; // Re-export the trait surface so downstream crates depend only on -// `magnotia_transcription` to access both local engines and the -// provider trait without a separate `magnotia_cloud_providers` +// `lumotia_transcription` to access both local engines and the +// provider trait without a separate `lumotia_cloud_providers` // dependency. This is the seam an OEM licensee uses when they swap // providers. -pub use magnotia_cloud_providers::{ +pub use lumotia_cloud_providers::{ CostClass, EngineProfile, NetworkRequirement, ProviderCapabilities, ProviderId, ProviderKind, ProviderTranscript, TranscriptionProvider, }; diff --git a/crates/transcription/src/local_engine.rs b/crates/transcription/src/local_engine.rs index 561ef84..a1d486d 100644 --- a/crates/transcription/src/local_engine.rs +++ b/crates/transcription/src/local_engine.rs @@ -1,11 +1,12 @@ use std::path::Path; -use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; use std::time::Instant; use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult}; -use magnotia_core::error::{MagnotiaError, Result}; -use magnotia_core::types::{ +use lumotia_core::error::{Error, Result}; +use lumotia_core::types::{ AudioSamples, EngineName, ModelId, Segment, Transcript, TranscriptionOptions, }; @@ -28,7 +29,7 @@ pub struct SpeechModelAdapter(pub Box); impl Transcriber for SpeechModelAdapter { fn capabilities(&self) -> TranscriberCapabilities { TranscriberCapabilities { - sample_rate: magnotia_core::constants::WHISPER_SAMPLE_RATE, + sample_rate: lumotia_core::constants::WHISPER_SAMPLE_RATE, channels: 1, supports_initial_prompt: false, } @@ -48,7 +49,7 @@ impl Transcriber for SpeechModelAdapter { let result: TranscriptionResult = self .0 .transcribe(samples, &opts) - .map_err(|e| MagnotiaError::TranscriptionFailed(e.to_string()))?; + .map_err(|e| Error::TranscriptionFailed(e.to_string()))?; Ok(result .segments .unwrap_or_default() @@ -60,6 +61,32 @@ impl Transcriber for SpeechModelAdapter { }) .collect()) } + + /// SAFETY: `transcribe-rs` owns the Parakeet decoder behind a `&mut + /// self` call that does not surface a cancellation hook. The best we + /// can do without forking transcribe-rs is short-circuit BEFORE the + /// decode call when the live session has already requested abort — + /// the same boundary the `Drop for InferenceTask` cancellation + /// route arrives through. Once the decode is in flight it runs to + /// completion, but the live session's `drain_inference` timeout + /// still drops our receiver, so the wedged orphan thread exits the + /// instant it tries to send its result. The engine `Mutex` is held + /// only across THIS call, so a future task will not deadlock — it + /// simply queues until the orphan releases. Documenting the + /// uncancellable middle so future audits don't get surprised. + fn transcribe_sync_with_abort( + &mut self, + samples: &[f32], + options: &TranscriptionOptions, + abort_flag: Arc, + ) -> Result> { + if abort_flag.load(std::sync::atomic::Ordering::Relaxed) { + return Err(Error::TranscriptionFailed( + "transcription aborted before decoder dispatch".to_string(), + )); + } + self.transcribe_sync(samples, options) + } } /// Owns the currently-loaded speech backend and serialises inference @@ -140,7 +167,7 @@ impl LocalEngine { options: &TranscriptionOptions, ) -> Result { let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner()); - let backend = guard.as_mut().ok_or(MagnotiaError::EngineNotLoaded)?; + let backend = guard.as_mut().ok_or(Error::EngineNotLoaded)?; let start = Instant::now(); let segments = backend.transcribe_sync(audio.samples(), options)?; @@ -155,12 +182,41 @@ impl LocalEngine { inference_ms, }) } + + /// Cancellable variant of `transcribe_sync`. Pipes `abort_flag` + /// through to the backend so the live-session drain timeout can + /// break a wedged whisper-rs decode out of its loop. Every backend + /// MUST implement the trait method (no default impl) — Parakeet's + /// adapter, for example, honours the flag at the pre-decode + /// boundary even though the in-flight decode itself is opaque. + pub fn transcribe_sync_with_abort( + &self, + audio: &AudioSamples, + options: &TranscriptionOptions, + abort_flag: Arc, + ) -> Result { + let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner()); + let backend = guard.as_mut().ok_or(Error::EngineNotLoaded)?; + + let start = Instant::now(); + let segments = backend.transcribe_sync_with_abort(audio.samples(), options, abort_flag)?; + let inference_ms = start.elapsed().as_millis() as u64; + + Ok(TimedTranscript { + transcript: Transcript::new( + segments, + options.language.clone().unwrap_or_else(|| "en".to_string()), + audio.duration_secs(), + ), + inference_ms, + }) + } } /// Thin wrapper over `ParakeetModel` that overrides `transcribe_raw` to /// request word-granularity segments. `transcribe-rs` 0.3's trait impl for /// `ParakeetModel::transcribe_raw` ignores `TranscribeOptions` and uses -/// `TimestampGranularity::Token` (per-subword) — which surfaces in Magnotia as +/// `TimestampGranularity::Token` (per-subword) — which surfaces in Lumotia as /// "T Est Ing . One , Two , Three" output. The concrete-type method /// `ParakeetModel::transcribe_with` accepts `ParakeetParams` with an /// explicit granularity; this wrapper exposes that to the trait object. @@ -197,7 +253,7 @@ impl transcribe_rs::SpeechModel for ParakeetWordGranularity { pub fn load_parakeet(model_dir: &Path) -> Result> { use transcribe_rs::onnx::Quantization; let model = transcribe_rs::onnx::parakeet::ParakeetModel::load(model_dir, &Quantization::Int8) - .map_err(|e| MagnotiaError::TranscriptionFailed(format!("Failed to load Parakeet: {e}")))?; + .map_err(|e| Error::TranscriptionFailed(format!("Failed to load Parakeet: {e}")))?; Ok(Box::new(SpeechModelAdapter(Box::new( ParakeetWordGranularity(model), )))) @@ -207,13 +263,17 @@ pub fn load_parakeet(model_dir: &Path) -> Result> { #[cfg(feature = "whisper")] pub fn load_whisper(model_path: &Path) -> Result> { let backend = WhisperRsBackend::load(model_path) - .map_err(|e| MagnotiaError::TranscriptionFailed(format!("Failed to load Whisper: {e}")))?; + .map_err(|e| Error::TranscriptionFailed(format!("Failed to load Whisper: {e}")))?; Ok(Box::new(backend)) } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::Ordering; + use transcribe_rs::{ + ModelCapabilities, TranscribeError, TranscribeOptions, TranscriptionResult, + }; #[test] fn engine_reports_not_available_before_loading() { @@ -222,4 +282,86 @@ mod tests { assert!(engine.loaded_model_id().is_none()); assert!(engine.capabilities().is_none()); } + + /// Minimal fake `SpeechModel` for the Parakeet adapter tests. Records + /// whether `transcribe_raw` was called so we can prove the pre-decode + /// abort short-circuit actually fires. + struct FakeSpeechModel { + call_count: Arc, + } + + impl transcribe_rs::SpeechModel for FakeSpeechModel { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities { + name: "fake", + engine_id: "fake", + sample_rate: 16_000, + languages: &[], + supports_timestamps: false, + supports_translation: false, + supports_streaming: false, + } + } + + fn transcribe_raw( + &mut self, + _samples: &[f32], + _options: &TranscribeOptions, + ) -> std::result::Result { + self.call_count + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Ok(TranscriptionResult { + text: String::new(), + segments: Some(Vec::new()), + }) + } + } + + #[test] + fn speech_model_adapter_short_circuits_when_abort_set_pre_dispatch() { + // Lifecycle-2 regression: without an explicit + // `transcribe_sync_with_abort` impl, SpeechModelAdapter used to + // inherit a default that silently dropped the abort flag and + // ran the decoder anyway. With the trait method made required, + // the adapter now checks the flag at the safest available + // boundary (pre-decode) and short-circuits. + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let model = Box::new(FakeSpeechModel { + call_count: call_count.clone(), + }); + let mut adapter = SpeechModelAdapter(model); + + let abort = Arc::new(AtomicBool::new(true)); + let options = TranscriptionOptions::default(); + let res = adapter.transcribe_sync_with_abort(&[0.0_f32; 16], &options, abort); + assert!(res.is_err(), "pre-set abort flag must short-circuit"); + assert_eq!( + call_count.load(Ordering::Relaxed), + 0, + "underlying decoder must NOT be called when abort was set before dispatch" + ); + } + + #[test] + fn speech_model_adapter_dispatches_decoder_when_abort_clear() { + // Companion to the short-circuit test: when the abort flag is + // clear at dispatch time, the adapter must still call the + // underlying decoder. Without this we'd have killed + // transcription entirely. + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let model = Box::new(FakeSpeechModel { + call_count: call_count.clone(), + }); + let mut adapter = SpeechModelAdapter(model); + + let abort = Arc::new(AtomicBool::new(false)); + let options = TranscriptionOptions::default(); + let res = adapter.transcribe_sync_with_abort(&[0.0_f32; 16], &options, abort); + assert!(res.is_ok(), "clear abort flag must allow dispatch"); + assert_eq!( + call_count.load(Ordering::Relaxed), + 1, + "decoder must run exactly once when abort flag is clear" + ); + } } diff --git a/crates/transcription/src/model_manager.rs b/crates/transcription/src/model_manager.rs index aaafcb2..a42bbbd 100644 --- a/crates/transcription/src/model_manager.rs +++ b/crates/transcription/src/model_manager.rs @@ -2,9 +2,9 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{LazyLock, Mutex}; -use magnotia_core::error::{MagnotiaError, Result}; -use magnotia_core::model_registry::{find_model, ModelFile}; -use magnotia_core::types::{DownloadProgress, ModelId}; +use lumotia_core::error::{Error, Result}; +use lumotia_core::model_registry::{find_model, ModelFile}; +use lumotia_core::types::{DownloadProgress, ModelId}; static ACTIVE_DOWNLOADS: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); @@ -18,9 +18,9 @@ impl DownloadReservation { let id = id.as_str().to_string(); let mut active = ACTIVE_DOWNLOADS .lock() - .map_err(|_| MagnotiaError::DownloadFailed("download lock poisoned".into()))?; + .map_err(|_| Error::DownloadFailed("download lock poisoned".into()))?; if !active.insert(id.clone()) { - return Err(MagnotiaError::DownloadFailed(format!( + return Err(Error::DownloadFailed(format!( "download already in progress for {id}" ))); } @@ -37,15 +37,15 @@ impl Drop for DownloadReservation { } /// Resolve the models storage directory. -/// Windows: %LOCALAPPDATA%/magnotia/models -/// Unix: ~/.magnotia/models +/// Windows: %LOCALAPPDATA%/lumotia/models +/// Unix: ~/.lumotia/models pub fn models_dir() -> PathBuf { - magnotia_core::paths::app_paths().models_dir() + lumotia_core::paths::app_paths().models_dir() } /// Get the directory path where a specific model's files are stored. pub fn model_dir(id: &ModelId) -> PathBuf { - magnotia_core::paths::app_paths().speech_model_dir(id) + lumotia_core::paths::app_paths().speech_model_dir(id) } /// Check whether all files for a model have been downloaded. @@ -61,7 +61,7 @@ pub fn is_downloaded(id: &ModelId) -> bool { /// List all downloaded model IDs. pub fn list_downloaded() -> Vec { - magnotia_core::model_registry::all_models() + lumotia_core::model_registry::all_models() .iter() .filter(|m| is_downloaded(&m.id)) .map(|m| m.id.clone()) @@ -74,13 +74,13 @@ pub fn list_downloaded() -> Vec { /// For files that declare a `sha256` checksum we validate an existing /// complete file before skipping the download — a truncated or /// tampered file gets redownloaded automatically (pattern ported from -/// `magnotia-llm`'s model_manager, item #8 in the Whisper ecosystem brief). +/// `lumotia-llm`'s model_manager, item #8 in the Whisper ecosystem brief). pub async fn download( id: &ModelId, progress: impl Fn(DownloadProgress) + Send + 'static, ) -> Result<()> { let _reservation = DownloadReservation::acquire(id)?; - let entry = find_model(id).ok_or_else(|| MagnotiaError::ModelNotFound(id.clone()))?; + let entry = find_model(id).ok_or_else(|| Error::ModelNotFound(id.clone()))?; let dir = model_dir(id); std::fs::create_dir_all(&dir)?; @@ -95,10 +95,24 @@ pub async fn download( match sha256_of_file(&dest) { Ok(actual) if actual.eq_ignore_ascii_case(file.sha256) => continue, Ok(_actual) => { - let _ = std::fs::remove_file(&dest); + // Rev-5 reversibility kill (atomiser 2026-05-12): + // the previous implementation called + // `remove_file(&dest)` here before the network + // round-trip. A network blip / power loss between + // the unlink and `rename(tmp, dest)` in + // `download_file` left users with neither the old + // (corrupt) model file nor a fresh one — Whisper + // models are 1.5–3 GB and the user is offline by + // hypothesis. + // + // We now leave the existing file in place. + // `download_file` writes to a `.part` sibling and + // only `rename`s over `dest` once the SHA matches. + // The bad file is atomically replaced on success; + // on failure the user keeps what they had. } Err(e) => { - return Err(MagnotiaError::DownloadFailed(format!( + return Err(Error::DownloadFailed(format!( "failed to verify existing {}: {e}", file.filename ))); @@ -113,13 +127,10 @@ pub async fn download( } fn verified_manifest_path(dir: &Path) -> PathBuf { - dir.join(".magnotia-verified") + dir.join(".lumotia-verified") } -fn verified_manifest_matches( - entry: &magnotia_core::model_registry::ModelEntry, - dir: &Path, -) -> bool { +fn verified_manifest_matches(entry: &lumotia_core::model_registry::ModelEntry, dir: &Path) -> bool { let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) { Ok(contents) => contents, Err(_) => return false, @@ -140,7 +151,7 @@ fn verified_manifest_matches( } fn write_verified_manifest( - entry: &magnotia_core::model_registry::ModelEntry, + entry: &lumotia_core::model_registry::ModelEntry, dir: &Path, ) -> std::io::Result<()> { let mut lines = Vec::with_capacity(entry.files.len() + 1); @@ -149,10 +160,33 @@ fn write_verified_manifest( let size = std::fs::metadata(dir.join(file.filename))?.len(); lines.push(format!("{}\t{}\t{}", file.filename, file.sha256, size)); } - std::fs::write( - verified_manifest_path(dir), - format!("{}\n", lines.join("\n")), - ) + let final_path = verified_manifest_path(dir); + let body = format!("{}\n", lines.join("\n")); + + // Rev-5 atomicity (atomiser 2026-05-12): the previous + // implementation called `fs::write(final_path, body)` directly, + // which truncates-then-writes. A power loss or kill mid-write + // leaves the manifest empty or partial; on next boot + // `verified_manifest_matches` reports `false` and the loader + // re-downloads the model even though all the GB-sized files are + // intact on disk. + // + // Write to a sibling `.tmp` first, fsync the data, then `rename` + // — POSIX `rename` is atomic within a directory, so a reader sees + // either the old manifest or the new one, never a partial. + let tmp_path = final_path.with_extension("tmp"); + { + use std::io::Write; + let mut tmp = std::fs::File::create(&tmp_path)?; + tmp.write_all(body.as_bytes())?; + tmp.flush()?; + // Best-effort fsync. If the platform/filesystem can't sync + // (e.g. some test runners on tmpfs), the rename is still + // atomic at the directory entry level, which is the property + // we actually care about for "no torn manifest". + let _ = tmp.sync_all(); + } + std::fs::rename(&tmp_path, &final_path) } /// Non-streaming SHA256 of a file on disk. Used by `download()` to @@ -196,7 +230,7 @@ async fn download_file( let client = reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(30)) .build() - .map_err(|e| MagnotiaError::DownloadFailed(e.to_string()))?; + .map_err(|e| Error::DownloadFailed(e.to_string()))?; // Check for existing partial download (resume support) let existing_bytes = if part_path.exists() { @@ -215,12 +249,12 @@ async fn download_file( let response = request .send() .await - .map_err(|e| MagnotiaError::DownloadFailed(e.to_string()))?; + .map_err(|e| Error::DownloadFailed(e.to_string()))?; // If we requested Range but the server returned 200 (full file), the // server does not support resume. Rather than blindly appending a // full file on top of our partial bytes (which would produce a - // corrupt result), restart cleanly. This mirrors the magnotia-llm + // corrupt result), restart cleanly. This mirrors the lumotia-llm // ResumeUnsupported branch — item #8 of the brief. // // For the non-resume path, we still have to validate the status: @@ -237,14 +271,14 @@ async fn download_file( false } other => { - return Err(MagnotiaError::DownloadFailed(format!( + return Err(Error::DownloadFailed(format!( "resume request returned unexpected status {other}" ))); } } } else { if !response.status().is_success() { - return Err(MagnotiaError::DownloadFailed(format!( + return Err(Error::DownloadFailed(format!( "download returned HTTP {} for {}", response.status(), file.filename @@ -291,7 +325,7 @@ async fn download_file( } while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| MagnotiaError::DownloadFailed(e.to_string()))?; + let chunk = chunk.map_err(|e| Error::DownloadFailed(e.to_string()))?; std::io::Write::write_all(&mut out, &chunk)?; hasher.update(&chunk); downloaded += chunk.len() as u64; @@ -319,7 +353,7 @@ async fn download_file( let actual = format!("{:x}", hasher.finalize()); if actual != file.sha256 { let _ = std::fs::remove_file(&part_path); - return Err(MagnotiaError::DownloadFailed(format!( + return Err(Error::DownloadFailed(format!( "SHA256 mismatch for {}: expected {}, got {}", file.filename, file.sha256, actual ))); @@ -357,7 +391,7 @@ mod tests { let list = list_downloaded(); // In test environment, no models are downloaded // This just verifies the function doesn't panic - assert!(list.len() <= magnotia_core::model_registry::all_models().len()); + assert!(list.len() <= lumotia_core::model_registry::all_models().len()); } #[test] @@ -481,7 +515,7 @@ mod tests { let file = ModelFile { filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), url: leak(format!("http://{addr}/fixture.bin")), - size: magnotia_core::types::Megabytes(0), + size: lumotia_core::types::Megabytes(0), sha256: leak(expected_sha.clone()), }; let id = ModelId::new("test-fixture"); @@ -516,7 +550,7 @@ mod tests { let file = ModelFile { filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), url: leak(format!("http://{addr}/fixture.bin")), - size: magnotia_core::types::Megabytes(0), + size: lumotia_core::types::Megabytes(0), sha256: leak(expected_sha), }; let id = ModelId::new("test-fixture"); @@ -571,7 +605,7 @@ mod tests { let file = ModelFile { filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), url: leak(format!("http://{addr}/fixture.bin")), - size: magnotia_core::types::Megabytes(0), + size: lumotia_core::types::Megabytes(0), sha256: leak("0".repeat(64)), }; let id = ModelId::new("test-fixture"); @@ -599,7 +633,7 @@ mod tests { let file = ModelFile { filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), url: leak(format!("http://{addr}/fixture.bin")), - size: magnotia_core::types::Megabytes(0), + size: lumotia_core::types::Megabytes(0), sha256: leak("deadbeef".repeat(8)), }; let id = ModelId::new("test-fixture"); @@ -616,4 +650,124 @@ mod tests { let part = dest.with_extension("bin.part"); assert!(!part.exists(), "failed hash must clean up the .part file"); } + + /// Rev-5 regression (atomiser 2026-05-12). The previous `download()` + /// loop called `remove_file(&dest)` on SHA mismatch BEFORE the + /// network call, so a failing redownload left the user with + /// neither the old (corrupt-but-readable) Whisper model nor a + /// fresh one — 1.5–3 GB redownload over an already-flaky network. + /// + /// `download_file` is the per-file primitive that gets invoked + /// after the outer-loop decides to redownload. It already writes + /// via `.part` and atomically renames. This test asserts the + /// invariant the fix relies on: when `download_file` fails (5xx + /// or SHA mismatch), an existing sentinel file at `dest` is left + /// in place. The outer-loop no longer deletes pre-emptively, so + /// this proves end-to-end: a failed redownload preserves the old + /// file. + #[tokio::test] + async fn download_file_failure_preserves_existing_dest_file() { + let addr = spawn_500_server().await; + + let dir = tempdir().unwrap(); + let dest = dir.path().join("fixture.bin"); + // Existing "old model" file the user already had on disk. + std::fs::write(&dest, b"OLD").unwrap(); + + let file = ModelFile { + filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), + url: leak(format!("http://{addr}/fixture.bin")), + size: lumotia_core::types::Megabytes(0), + sha256: leak("0".repeat(64)), + }; + let id = ModelId::new("test-fixture"); + + let _ = download_file(&file, &dest, &id, &|_| ()) + .await + .expect_err("5xx must fail"); + + assert!( + dest.exists(), + "download failure must leave the existing dest in place" + ); + let preserved = std::fs::read(&dest).unwrap(); + assert_eq!( + preserved, b"OLD", + "existing file contents must be untouched on failed download" + ); + } + + /// Rev-5 atomic-manifest regression (atomiser 2026-05-12). The + /// previous `write_verified_manifest` called `fs::write` directly, + /// which truncates-then-writes. A power loss mid-write left an + /// empty or torn manifest; on next boot the loader couldn't match + /// the existing GB-sized model files and redownloaded everything. + /// + /// The fix writes to a sibling `.tmp` first then `rename`s — POSIX + /// `rename` is atomic. This test exercises that path: write a + /// valid manifest, then prove a stale `.tmp` left behind from a + /// crashed previous attempt does NOT survive a fresh write (the + /// rename replaces it), and that the final manifest matches. + #[test] + fn manifest_write_is_atomic() { + use lumotia_core::model_registry::{ + AccuracyTier, Engine, LanguageSupport, ModelEntry, ModelFile, SpeedTier, + }; + use lumotia_core::types::Megabytes; + use sha2::Digest; + + let dir = tempdir().unwrap(); + let file_path = dir.path().join("model.bin"); + std::fs::write(&file_path, b"payload").unwrap(); + + let entry = ModelEntry { + id: ModelId::new("test-manifest"), + engine: Engine::Whisper, + display_name: "test", + disk_size: Megabytes(0), + ram_required: Megabytes(0), + speed_tier: SpeedTier::Instant, + accuracy_tier: AccuracyTier::Great, + languages: LanguageSupport::EnglishOnly, + files: vec![ModelFile { + filename: leak( + file_path + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(), + ), + url: "http://example.invalid/model.bin", + size: Megabytes(0), + sha256: leak(format!("{:x}", sha2::Sha256::digest(b"payload"))), + }], + description: "", + }; + + let manifest_path = verified_manifest_path(dir.path()); + let tmp_path = manifest_path.with_extension("tmp"); + + // Simulate a crashed previous write leaving a stale .tmp. + std::fs::write(&tmp_path, b"PARTIAL_GARBAGE").unwrap(); + assert!(tmp_path.exists()); + + write_verified_manifest(&entry, dir.path()).expect("write manifest"); + + assert!( + manifest_path.exists(), + "final manifest must exist after atomic write" + ); + assert!(!tmp_path.exists(), "stale .tmp must be removed by rename"); + + let body = std::fs::read_to_string(&manifest_path).unwrap(); + assert!(body.starts_with("version\t1")); + assert!( + body.ends_with('\n'), + "manifest must be flushed completely with terminating newline" + ); + assert!( + verified_manifest_matches(&entry, dir.path()), + "manifest written via tmp+rename must round-trip verify" + ); + } } diff --git a/crates/transcription/src/orchestrator.rs b/crates/transcription/src/orchestrator.rs index f412292..c85c1fb 100644 --- a/crates/transcription/src/orchestrator.rs +++ b/crates/transcription/src/orchestrator.rs @@ -1,7 +1,7 @@ //! `Orchestrator` is the single entry point for transcription. Tauri //! commands resolve a provider through it; nothing else calls a //! provider directly. This is where the AGPL OEM trait boundary meets -//! Wyrdnote's local engines. +//! Lumotia's local engines. //! //! `LocalProviderAdapter` lives here, not as an `impl //! TranscriptionProvider for LocalEngine`. The adapter wraps an @@ -18,12 +18,12 @@ use std::sync::Arc; use async_trait::async_trait; -use magnotia_cloud_providers::{ +use lumotia_cloud_providers::{ CostClass, EngineProfile, ProviderCapabilities, ProviderId, ProviderKind, ProviderTranscript, TranscriptionProvider, }; -use magnotia_core::error::{MagnotiaError, Result}; -use magnotia_core::types::{AudioSamples, TranscriptionOptions}; +use lumotia_core::error::{Error, Result}; +use lumotia_core::types::{AudioSamples, TranscriptionOptions}; use crate::local_engine::LocalEngine; use crate::registry::EngineRegistry; @@ -68,7 +68,7 @@ impl TranscriptionProvider for LocalProviderAdapter { ProviderCapabilities { sample_rate: local .map(|c| c.sample_rate) - .unwrap_or(magnotia_core::constants::WHISPER_SAMPLE_RATE), + .unwrap_or(lumotia_core::constants::WHISPER_SAMPLE_RATE), channels: local.map(|c| c.channels).unwrap_or(1), initial_prompt_supported: local.map(|c| c.supports_initial_prompt).unwrap_or(false), language_hint_supported: true, @@ -81,7 +81,7 @@ impl TranscriptionProvider for LocalProviderAdapter { if self.engine.is_loaded() { Ok(()) } else { - Err(MagnotiaError::EngineNotLoaded) + Err(Error::EngineNotLoaded) } } @@ -93,7 +93,7 @@ impl TranscriptionProvider for LocalProviderAdapter { let engine = self.engine.clone(); let timed = tokio::task::spawn_blocking(move || engine.transcribe_sync(&audio, &options)) .await - .map_err(|e| MagnotiaError::TranscriptionFailed(format!("Task join error: {e}")))??; + .map_err(|e| Error::TranscriptionFailed(format!("Task join error: {e}")))??; Ok(ProviderTranscript { transcript: timed.transcript, inference_ms: timed.inference_ms, @@ -124,7 +124,7 @@ impl Orchestrator { pub fn resolve(&self, profile: &EngineProfile) -> Result> { self.registry .get(&profile.engine_id) - .ok_or_else(|| MagnotiaError::ProviderNotRegistered(profile.engine_id.to_string())) + .ok_or_else(|| Error::ProviderNotRegistered(profile.engine_id.to_string())) } /// Transcribe audio using the provider named in the profile. The @@ -153,7 +153,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use magnotia_core::types::{Segment, Transcript}; + use lumotia_core::types::{Segment, Transcript}; /// Mock provider that returns a canned transcript and counts /// invocations. Used to validate the orchestrator's dispatch logic @@ -223,7 +223,7 @@ mod tests { #[tokio::test] async fn orchestrator_dispatches_to_named_provider() { let calls = Arc::new(AtomicUsize::new(0)); - let registry = build_registry_with("canned", "hello wyrdnote", calls.clone()); + let registry = build_registry_with("canned", "hello lumotia", calls.clone()); let orchestrator = Orchestrator::new(registry); let profile = EngineProfile::new(ProviderId::new("canned")); @@ -232,7 +232,7 @@ mod tests { .await .expect("dispatch succeeds"); - assert_eq!(result.transcript.text(), "hello wyrdnote"); + assert_eq!(result.transcript.text(), "hello lumotia"); assert_eq!(result.inference_ms, 7); assert_eq!(calls.load(Ordering::SeqCst), 1); } @@ -264,7 +264,7 @@ mod tests { let mut profile = EngineProfile::new(ProviderId::new("canned")); profile.language = Some("en".to_string()); - profile.initial_prompt = Some("Wyrdnote".to_string()); + profile.initial_prompt = Some("Lumotia".to_string()); let _ = orchestrator .transcribe(one_second_of_silence(), &profile) diff --git a/crates/transcription/src/registry.rs b/crates/transcription/src/registry.rs index 1e30af5..6d04624 100644 --- a/crates/transcription/src/registry.rs +++ b/crates/transcription/src/registry.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::sync::Arc; -use magnotia_cloud_providers::{ProviderId, TranscriptionProvider}; +use lumotia_cloud_providers::{ProviderId, TranscriptionProvider}; /// Catalogue of providers known to the orchestrator. pub struct EngineRegistry { @@ -82,11 +82,11 @@ mod tests { use super::*; use async_trait::async_trait; - use magnotia_cloud_providers::{ + use lumotia_cloud_providers::{ CostClass, EngineProfile, ProviderCapabilities, ProviderKind, ProviderTranscript, }; - use magnotia_core::error::Result; - use magnotia_core::types::{AudioSamples, Transcript, TranscriptionOptions}; + use lumotia_core::error::Result; + use lumotia_core::types::{AudioSamples, Transcript, TranscriptionOptions}; struct DummyProvider { id: ProviderId, diff --git a/crates/transcription/src/transcriber.rs b/crates/transcription/src/transcriber.rs index 4af1320..5482262 100644 --- a/crates/transcription/src/transcriber.rs +++ b/crates/transcription/src/transcriber.rs @@ -9,8 +9,11 @@ //! `whisper` feature — `WhisperRsBackend` (direct whisper-rs, the only //! path that pipes `initial_prompt`). -use magnotia_core::error::Result; -use magnotia_core::types::{Segment, TranscriptionOptions}; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use lumotia_core::error::Result; +use lumotia_core::types::{Segment, TranscriptionOptions}; /// Static capabilities a `Transcriber` advertises to callers. /// @@ -45,11 +48,41 @@ pub trait Transcriber: Send { samples: &[f32], options: &TranscriptionOptions, ) -> Result>; + + /// Variant of `transcribe_sync` that accepts an external abort flag. + /// + /// REQUIRED so every backend opts in to a cancellation story at + /// compile time. Without this requirement a default impl that + /// simply forwarded to `transcribe_sync` would silently re-introduce + /// the wedge for any non-whisper backend: the live session's + /// `drain_inference` timeout would set the flag, but the backend + /// would ignore it, the orphan inference thread would keep holding + /// the engine `Mutex`, and the next start/stop would deadlock on it. + /// + /// Implementer guidance: + /// * Backends that can react to mid-inference cancellation + /// (whisper-rs via `set_abort_callback_safe`) wire the flag into + /// their decoder's abort hook so the wedged inference can be + /// unstuck by the live session's drain timeout. + /// * Backends that genuinely cannot honour the flag mid-call + /// (synchronous external API calls, transcribe-rs adapters that + /// own opaque decoder state) MUST still implement this method — + /// even if the implementation is to check the flag at the safest + /// available boundary and otherwise dispatch to `transcribe_sync`. + /// Document the uncancellability with a `// SAFETY:` comment so a + /// future audit can find it. + fn transcribe_sync_with_abort( + &mut self, + samples: &[f32], + options: &TranscriptionOptions, + abort_flag: Arc, + ) -> Result>; } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::Ordering; #[test] fn transcriber_trait_is_object_safe() { @@ -58,4 +91,63 @@ mod tests { // method) this declaration fails to build. No runtime work. let _: Option> = None; } + + /// Fake backend that records whether the abort flag was observed + /// at dispatch time. Proves the compile-time-required + /// `transcribe_sync_with_abort` actually receives the flag instead + /// of silently falling back to a default impl that drops it on the + /// floor (the Lifecycle-2 wedge). + struct FlagSnoopingBackend { + observed_abort: bool, + } + + impl Transcriber for FlagSnoopingBackend { + fn capabilities(&self) -> TranscriberCapabilities { + TranscriberCapabilities { + sample_rate: 16_000, + channels: 1, + supports_initial_prompt: false, + } + } + + fn transcribe_sync( + &mut self, + _samples: &[f32], + _options: &TranscriptionOptions, + ) -> Result> { + Ok(Vec::new()) + } + + fn transcribe_sync_with_abort( + &mut self, + _samples: &[f32], + _options: &TranscriptionOptions, + abort_flag: Arc, + ) -> Result> { + self.observed_abort = abort_flag.load(Ordering::Relaxed); + Ok(Vec::new()) + } + } + + #[test] + fn transcribe_sync_with_abort_is_required_and_flag_is_observed() { + // Lifecycle-2 regression: removing the trait's default impl + // forces every backend to receive the abort flag at the call + // site. Without an explicit method on this fake backend the + // file wouldn't compile; with it, asserting the snoop is the + // runtime witness that the flag is actually piped through. + let mut backend = FlagSnoopingBackend { + observed_abort: false, + }; + let flag = Arc::new(AtomicBool::new(true)); + let options = TranscriptionOptions::default(); + let segs = backend + .transcribe_sync_with_abort(&[], &options, flag.clone()) + .expect("fake backend never errors"); + assert!(segs.is_empty()); + assert!( + backend.observed_abort, + "trait must hand the abort flag to the backend, not drop it via a default impl" + ); + } } diff --git a/crates/transcription/src/whisper_rs_backend.rs b/crates/transcription/src/whisper_rs_backend.rs index bed4b04..1926792 100644 --- a/crates/transcription/src/whisper_rs_backend.rs +++ b/crates/transcription/src/whisper_rs_backend.rs @@ -7,13 +7,15 @@ //! into Whisper. use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; -use magnotia_core::error::{MagnotiaError, Result}; -use magnotia_core::hardware::vulkan_loader_available; -use magnotia_core::tuning::{inference_thread_count, Workload}; -use magnotia_core::types::{Segment, TranscriptionOptions}; +use lumotia_core::error::{Error, Result}; +use lumotia_core::hardware::vulkan_loader_available; +use lumotia_core::tuning::{inference_thread_count, Workload}; +use lumotia_core::types::{Segment, TranscriptionOptions}; use crate::transcriber::{Transcriber, TranscriberCapabilities}; @@ -42,7 +44,7 @@ impl WhisperRsBackend { impl Transcriber for WhisperRsBackend { fn capabilities(&self) -> TranscriberCapabilities { TranscriberCapabilities { - sample_rate: magnotia_core::constants::WHISPER_SAMPLE_RATE, + sample_rate: lumotia_core::constants::WHISPER_SAMPLE_RATE, channels: 1, supports_initial_prompt: true, } @@ -57,17 +59,40 @@ impl Transcriber for WhisperRsBackend { &mut self, samples: &[f32], options: &TranscriptionOptions, + ) -> Result> { + self.transcribe_sync_inner(samples, options, None) + } + + /// Cancellable variant. Installs the abort flag as whisper-rs's + /// abort callback so a `drain_inference` timeout in the live session + /// can break the worker out of the decoding loop instead of letting + /// it run to completion against a stuck backend. + fn transcribe_sync_with_abort( + &mut self, + samples: &[f32], + options: &TranscriptionOptions, + abort_flag: Arc, + ) -> Result> { + self.transcribe_sync_inner(samples, options, Some(abort_flag)) + } +} + +impl WhisperRsBackend { + fn transcribe_sync_inner( + &mut self, + samples: &[f32], + options: &TranscriptionOptions, + abort_flag: Option>, ) -> Result> { tracing::info!( language = ?options.language, has_initial_prompt = options.initial_prompt.as_deref().map(|p| !p.is_empty()).unwrap_or(false), + has_abort_flag = abort_flag.is_some(), "WhisperRsBackend::transcribe_sync entering" ); let mut state = self.ctx.create_state().map_err(|e| { - MagnotiaError::TranscriptionFailed( - WhisperBackendError::State(e.to_string()).to_string(), - ) + Error::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string()) })?; let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); @@ -87,10 +112,17 @@ impl Transcriber for WhisperRsBackend { params.set_print_progress(false); params.set_print_realtime(false); + // Wire the per-task abort flag into whisper-rs. The closure is + // polled by whisper.cpp between decode steps; returning true + // tells the backend to abort the current inference. This is the + // cancellation route that closes the orphaned-thread loophole + // when a live session is stopped or wedges. + if let Some(flag) = abort_flag { + params.set_abort_callback_safe(move || flag.load(Ordering::Relaxed)); + } + state.full(params, samples).map_err(|e| { - MagnotiaError::TranscriptionFailed( - WhisperBackendError::Transcribe(e.to_string()).to_string(), - ) + Error::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()) })?; let n = state.full_n_segments(); @@ -103,7 +135,7 @@ impl Transcriber for WhisperRsBackend { let text = seg .to_str() .map_err(|e| { - MagnotiaError::TranscriptionFailed( + Error::TranscriptionFailed( WhisperBackendError::Transcribe(e.to_string()).to_string(), ) })? diff --git a/crates/transcription/tests/jfk_bench.rs b/crates/transcription/tests/jfk_bench.rs index 3470a8f..2ed32ed 100644 --- a/crates/transcription/tests/jfk_bench.rs +++ b/crates/transcription/tests/jfk_bench.rs @@ -2,20 +2,20 @@ //! Reports cold-load time, transcribe time, RTF, peak RSS. //! //! Gated on env vars so it never runs in CI without setup: -//! MAGNOTIA_WHISPER_TEST_MODEL=/path/to/ggml-tiny.bin -//! MAGNOTIA_WHISPER_TEST_AUDIO=/path/to/jfk.wav +//! LUMOTIA_WHISPER_TEST_MODEL=/path/to/ggml-tiny.bin +//! LUMOTIA_WHISPER_TEST_AUDIO=/path/to/jfk.wav use std::env; use std::time::Instant; #[test] fn jfk_transcription_benchmark() { - let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else { - eprintln!("MAGNOTIA_WHISPER_TEST_MODEL not set — skipping"); + let Ok(model_path) = env::var("LUMOTIA_WHISPER_TEST_MODEL") else { + eprintln!("LUMOTIA_WHISPER_TEST_MODEL not set — skipping"); return; }; - let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else { - eprintln!("MAGNOTIA_WHISPER_TEST_AUDIO not set — skipping"); + let Ok(audio_path) = env::var("LUMOTIA_WHISPER_TEST_AUDIO") else { + eprintln!("LUMOTIA_WHISPER_TEST_AUDIO not set — skipping"); return; }; diff --git a/crates/transcription/tests/thread_sweep.rs b/crates/transcription/tests/thread_sweep.rs index 81578a1..0f040db 100644 --- a/crates/transcription/tests/thread_sweep.rs +++ b/crates/transcription/tests/thread_sweep.rs @@ -1,24 +1,24 @@ //! Thread-count scaling sweep for Whisper Tiny. //! Runs the JFK clip at n_threads = 1, 2, 4, 6, 8, 12, prints RTF tables. -//! Env-gated by `MAGNOTIA_WHISPER_TEST_MODEL` + `MAGNOTIA_WHISPER_TEST_AUDIO`. +//! Env-gated by `LUMOTIA_WHISPER_TEST_MODEL` + `LUMOTIA_WHISPER_TEST_AUDIO`. //! -//! Now prints multiple panels driven by `MAGNOTIA_POWER_STATE_OVERRIDE` so +//! Now prints multiple panels driven by `LUMOTIA_POWER_STATE_OVERRIDE` so //! the helper's predicted thread count for each (power, GPU) combination //! can be compared against the empirical RTF data. use std::env; use std::time::Instant; -use magnotia_core::hardware::vulkan_loader_available; -use magnotia_core::tuning::{inference_thread_count, Workload}; +use lumotia_core::hardware::vulkan_loader_available; +use lumotia_core::tuning::{inference_thread_count, Workload}; use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; #[test] fn whisper_thread_count_sweep() { - let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else { + let Ok(model_path) = env::var("LUMOTIA_WHISPER_TEST_MODEL") else { return; }; - let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else { + let Ok(audio_path) = env::var("LUMOTIA_WHISPER_TEST_AUDIO") else { return; }; @@ -71,7 +71,7 @@ fn whisper_thread_count_sweep() { ); // Four panels: CPU and GPU axes for the predicted-helper-pick column, - // crossed with AC and battery via MAGNOTIA_POWER_STATE_OVERRIDE. + // crossed with AC and battery via LUMOTIA_POWER_STATE_OVERRIDE. let panels = [ ("AC, CPU", "ac", false), ("AC, GPU (Vulkan)", "ac", true), @@ -80,11 +80,11 @@ fn whisper_thread_count_sweep() { ]; for (label, power, gpu_offloaded_for_helper) in panels { - env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", power); + env::set_var("LUMOTIA_POWER_STATE_OVERRIDE", power); let helper_pick = inference_thread_count(Workload::Whisper, gpu_offloaded_for_helper); run_sweep_panel(label, helper_pick, &ctx, &samples, audio_secs, &targets); } - env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE"); + env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE"); } fn run_sweep_panel( diff --git a/crates/transcription/tests/whisper_rs_smoke.rs b/crates/transcription/tests/whisper_rs_smoke.rs index e3ed8b6..4236d91 100644 --- a/crates/transcription/tests/whisper_rs_smoke.rs +++ b/crates/transcription/tests/whisper_rs_smoke.rs @@ -1,17 +1,17 @@ //! Smoke test: whisper-rs 0.16 loads a GGUF model, transcribes silence, and //! accepts set_initial_prompt without panicking. //! -//! Runs only when `MAGNOTIA_WHISPER_TEST_MODEL` is set to the path of a +//! Runs only when `LUMOTIA_WHISPER_TEST_MODEL` is set to the path of a //! ggml/gguf whisper model on disk. Otherwise the test exits quiet. use std::env; #[test] fn whisper_rs_smoke_loads_and_transcribes() { - let model_path = match env::var("MAGNOTIA_WHISPER_TEST_MODEL") { + let model_path = match env::var("LUMOTIA_WHISPER_TEST_MODEL") { Ok(p) => p, Err(_) => { - eprintln!("MAGNOTIA_WHISPER_TEST_MODEL not set — skipping"); + eprintln!("LUMOTIA_WHISPER_TEST_MODEL not set — skipping"); return; } }; diff --git a/docs/architecture-map/01-frontend/README.md b/docs/architecture-map/01-frontend/README.md index 8b9dab3..fa22ffb 100644 --- a/docs/architecture-map/01-frontend/README.md +++ b/docs/architecture-map/01-frontend/README.md @@ -18,7 +18,7 @@ last_verified: 2026/05/09 - **File counts:** 7 pages, 25 components, 10 stores, 1 action, 16 utils, 1 type module, 3 locales, 4 routes (root + float/viewer/preview), 20 design system preview HTMLs, 3 design system JSX kits. - **Frameworks:** Svelte 5 (runes mode), SvelteKit 2.58, `@sveltejs/adapter-static` with `index.html` fallback (SPA), Vite 6, Tailwind v4 via `@tailwindcss/vite`, svelte-i18n 4, lucide-svelte for icons. - **Tauri SDK touchpoints:** `@tauri-apps/api` (core invoke, event, window) plus `plugin-autostart`, `plugin-dialog`, `plugin-global-shortcut`, `plugin-notification`, `plugin-opener`. SSR is disabled (`src/routes/+layout.js`) so the whole tree is client side only. -- **Persistence used directly by frontend:** `localStorage` for `magnotia_settings`, `magnotia_profiles`, `magnotia_task_lists`, `magnotia_templates`, `magnotia_locale`, plus a small handoff key for the viewer window. Preferences additionally persist via Tauri (`save_preferences`). +- **Persistence used directly by frontend:** `localStorage` for `lumotia_settings`, `lumotia_profiles`, `lumotia_task_lists`, `lumotia_templates`, `lumotia_locale`, plus a small handoff key for the viewer window. Preferences additionally persist via Tauri (`save_preferences`). - **Build commands:** `npm run dev` (`svelte-kit sync && vite dev`), `npm run build`, `npm run check` (svelte-check using `jsconfig.json`). ## Map of this slice @@ -44,21 +44,21 @@ last_verified: 2026/05/09 **In (frontend depends on Tauri runtime, slice 02).** - Sixty plus distinct `invoke()` commands. Full list with caller in [frontend-tauri-bridge.md](frontend-tauri-bridge.md). -- Tauri events listened to: `model-download-progress`, `parakeet-download-progress`, `magnotia:llm-download-progress`, `magnotia:hotkey-pressed`, `magnotia:open-wind-down`, `magnotia:preferences-changed`, `task-window-focus`, `preview-listening`, `preview-cleanup`, `preview-hide`, plus drag drop (`tauri://drag-drop`, `tauri://drag-enter`, `tauri://drag-leave`). +- Tauri events listened to: `model-download-progress`, `parakeet-download-progress`, `lumotia:llm-download-progress`, `lumotia:hotkey-pressed`, `lumotia:open-wind-down`, `lumotia:preferences-changed`, `task-window-focus`, `preview-listening`, `preview-cleanup`, `preview-hide`, plus drag drop (`tauri://drag-drop`, `tauri://drag-enter`, `tauri://drag-leave`). - Window APIs: `getCurrentWindow().minimize() / toggleMaximize() / setPosition() / label`. - Plugin imports loaded lazily: `@tauri-apps/plugin-global-shortcut`, `@tauri-apps/plugin-dialog`. **Out (frontend triggers behaviour back into the runtime).** -- DOM `CustomEvent` bus on `window` carries internal traffic (`magnotia:start-timer`, `magnotia:toggle-recording`, `magnotia:task-completed`, etc). The Rust side does not listen to these; they are intra frontend. -- Tauri `emit()` is used for `magnotia:preferences-changed` only, to fan preference updates across windows. +- DOM `CustomEvent` bus on `window` carries internal traffic (`lumotia:start-timer`, `lumotia:toggle-recording`, `lumotia:task-completed`, etc). The Rust side does not listen to these; they are intra frontend. +- Tauri `emit()` is used for `lumotia:preferences-changed` only, to fan preference updates across windows. - Multi window orchestration: pages call `open_task_window`, `open_viewer_window`, `open_preview_window` to ask Rust to spawn secondary webviews. **Other slices that read frontend conventions.** - Slice 02 (Tauri runtime) emits the events listed above and registers commands the frontend invokes. - Slice 03 (audio + transcription) ships partial results via a `Channel` (Tauri 2 typed channel) opened in `DictationPage`. -- Slice 04 (LLM, formatting, MCP) emits `magnotia:llm-download-progress` and `cleanup_transcript_text_cmd` results consumed by Dictation. +- Slice 04 (LLM, formatting, MCP) emits `lumotia:llm-download-progress` and `cleanup_transcript_text_cmd` results consumed by Dictation. - Slice 05 (storage, hotkey, build) supplies `add_transcript`, `delete_transcript`, profiles, tasks and the evdev hotkey backend. ## Existing in repo docs (do not duplicate) @@ -80,7 +80,7 @@ This slice index is the navigation hub. The map files referenced above carry the 3. **`@ts-nocheck` is widespread.** `+layout.svelte`, `DictationPage.svelte`, `FilesPage.svelte`, `FirstRunPage.svelte` and the float/viewer/preview layouts all opt out of TypeScript. Type safety stops at the page boundary. 4. **`SettingsPage.svelte` is 2 484 lines.** Phase 9c handover already flagged this. `SettingsGroup.svelte` exists but the deeper restructure into seven progressive disclosure groups plus search has been deferred. 5. **`profiles` redirect is a dead route.** `+page.svelte:13` still rewrites `page.current === "profiles"` to `"settings"`, suggesting the old profiles page was removed but call sites may persist. Worth grepping and deleting the redirect after a release. -6. **Cross window settings sync uses `localStorage` `storage` events.** `float/+layout@.svelte` listens for `storage` to apply settings, while preferences use the dedicated `magnotia:preferences-changed` Tauri event. Inconsistent. Settings sync via `storage` is fragile because it only fires on cross document writes. +6. **Cross window settings sync uses `localStorage` `storage` events.** `float/+layout@.svelte` listens for `storage` to apply settings, while preferences use the dedicated `lumotia:preferences-changed` Tauri event. Inconsistent. Settings sync via `storage` is fragile because it only fires on cross document writes. 7. **`shims.d.ts` next to the lib root.** Single shim file at `src/lib/shims.d.ts`. Worth verifying it is still needed (Svelte 5 + SvelteKit ship most ambient types now). 8. **`design-system/ui_kits/`** contains JSX components and an HTML index. They are reference, not live, and should not import from the runtime tree. Confirm the build excludes them. 9. **`speaker.svelte.ts` is ten lines.** A near empty store. Used by `SpeakerButton.svelte`. Consider folding into a util if it never grows. diff --git a/docs/architecture-map/01-frontend/actions-utils-types.md b/docs/architecture-map/01-frontend/actions-utils-types.md index 299230e..9e14765 100644 --- a/docs/architecture-map/01-frontend/actions-utils-types.md +++ b/docs/architecture-map/01-frontend/actions-utils-types.md @@ -39,7 +39,7 @@ Caches OS info via `invoke("os_info")` (or similar; check `lib.rs`). Exposes `lo ### `settingsMigrations.ts` (134 LOC) -Versioned migrations for `magnotia_settings`. Holds `CURRENT_SETTINGS_VERSION = 1`. Envelope shape: `{ version: number, data: T }`. Reads bare unversioned blobs as v0. `loadSettingsWithMigration(key, defaults)` walks the chain and toasts on corruption. +Versioned migrations for `lumotia_settings`. Holds `CURRENT_SETTINGS_VERSION = 1`. Envelope shape: `{ version: number, data: T }`. Reads bare unversioned blobs as v0. `loadSettingsWithMigration(key, defaults)` walks the chain and toasts on corruption. ### `frontmatter.ts` (148 LOC) diff --git a/docs/architecture-map/01-frontend/app-shell-and-styling.md b/docs/architecture-map/01-frontend/app-shell-and-styling.md index 6a8777d..941ee76 100644 --- a/docs/architecture-map/01-frontend/app-shell-and-styling.md +++ b/docs/architecture-map/01-frontend/app-shell-and-styling.md @@ -22,7 +22,7 @@ last_verified: 2026/05/09 ### `src/app.html` (13 LOC) -Standard SvelteKit document. Sets `lang="en"`, charset, viewport, favicon, title (`Magnotia`), and applies `data-sveltekit-preload-data="hover"` on ``. The body content is wrapped in `
` so the SvelteKit-injected children render inline. +Standard SvelteKit document. Sets `lang="en"`, charset, viewport, favicon, title (`Lumotia`), and applies `data-sveltekit-preload-data="hover"` on ``. The body content is wrapped in `
` so the SvelteKit-injected children render inline. ### `src/app.d.ts` (8 LOC) diff --git a/docs/architecture-map/01-frontend/components.md b/docs/architecture-map/01-frontend/components.md index 74cbca3..c28d8a7 100644 --- a/docs/architecture-map/01-frontend/components.md +++ b/docs/architecture-map/01-frontend/components.md @@ -25,7 +25,7 @@ last_verified: 2026/05/09 | `Titlebar` | 80 | `src/lib/components/Titlebar.svelte` | Custom window chrome for non Linux (frameless windows). Min/max/close + drag area + sidebar aligned spacer. | | `ToastViewport` | 143 | `src/lib/components/ToastViewport.svelte` | Bottom right toast stack. Reads from the global `toasts` store. Honours `prefers-reduced-motion`. | | `ResizeHandles` | 67 | `src/lib/components/ResizeHandles.svelte` | Invisible 5 px margins for frameless resize. Linux uses native, so `+layout.svelte` suppresses this. | -| `FocusTimer` | 298 | `src/lib/components/FocusTimer.svelte` | Floating top right SVG progress ring. Listens for `magnotia:start-timer` window events and delegates to the focus timer store. Cancel hover, success flourish. Hidden on float and viewer windows by URL probe. | +| `FocusTimer` | 298 | `src/lib/components/FocusTimer.svelte` | Floating top right SVG progress ring. Listens for `lumotia:start-timer` window events and delegates to the focus timer store. Cancel hover, success flourish. Hidden on float and viewer windows by URL probe. | | `MorningTriageModal` | 356 | `src/lib/components/MorningTriageModal.svelte` | Phase 5 modal. Self gated on `settings.ritualsMorning` and time of day. Mounted globally so it appears over any page. | | `TaskSidebar` | 97 | `src/lib/components/TaskSidebar.svelte` | Optional right side dock that appears when `page.taskSidebarOpen`. Quick add input, top tasks, link out to the full Tasks page. | @@ -54,8 +54,8 @@ last_verified: 2026/05/09 | Component | LOC | Purpose | |---|---|---| -| `WipTaskList` | 153 | Renders task rows with energy chips, completion checkbox, expansion to show micro steps, and a "start focus timer" button (dispatches `magnotia:start-timer`). | -| `MicroSteps` | 310 | Per task expansion: nudge bus integration, micro step suggestions from the LLM (`magnotia:microstep-generated`), step completion (`magnotia:step-completed`), per task implementation rules. | +| `WipTaskList` | 153 | Renders task rows with energy chips, completion checkbox, expansion to show micro steps, and a "start focus timer" button (dispatches `lumotia:start-timer`). | +| `MicroSteps` | 310 | Per task expansion: nudge bus integration, micro step suggestions from the LLM (`lumotia:microstep-generated`), step completion (`lumotia:step-completed`), per task implementation rules. | | `EnergyChip` | 106 | Low/medium/high energy selector. Used on task rows and in TasksPage quick add. | | `CompletionSparkline` | 92 | 7 day completion bar chart. Animated entrance with 30 ms stagger, respects `prefers-reduced-motion`. Reads from `recentCompletions` store. | | `VisualTimer` | 33 | Compact visual timer pill used inside MicroSteps and the float window. | diff --git a/docs/architecture-map/01-frontend/design-system.md b/docs/architecture-map/01-frontend/design-system.md index a05173a..5f7605d 100644 --- a/docs/architecture-map/01-frontend/design-system.md +++ b/docs/architecture-map/01-frontend/design-system.md @@ -9,14 +9,14 @@ last_verified: 2026/05/09 > **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Design system -**Plain English summary.** Reference material. The runtime styles live in `src/app.css`. Everything under `src/design-system/` is brand documentation, preview pages, JSX UI kits, and ground truth source for the magnotia-design Claude skill. None of it is imported by the runtime SvelteKit app. +**Plain English summary.** Reference material. The runtime styles live in `src/app.css`. Everything under `src/design-system/` is brand documentation, preview pages, JSX UI kits, and ground truth source for the lumotia-design Claude skill. None of it is imported by the runtime SvelteKit app. ## At a glance - **Path:** `src/design-system/` - **Files:** - - `README.md`. Brand and content rules. (Magnotia by CORBEL.) - - `SKILL.md`. magnotia-design skill manifest (`user-invocable: true`). The skill copies assets out and writes throwaway HTML or production code on demand. + - `README.md`. Brand and content rules. (Lumotia by CORBEL.) + - `SKILL.md`. lumotia-design skill manifest (`user-invocable: true`). The skill copies assets out and writes throwaway HTML or production code on demand. - `colors_and_type.css`. Mirror of the runtime `@theme` tokens for buildless preview pages. Intentional duplication, kept in sync by hand. - `preview/`. 20 buildless HTML spec cards. - `ui_kits/`. JSX recreations (`DictationPage.jsx`, `OtherPages.jsx`, `Sidebar.jsx`) plus an `index.html` and a README. @@ -52,7 +52,7 @@ Three `.jsx` files plus `index.html` and a README. Reproduce the desktop pages i ## Why it lives in `src/` -The design system files sit under `src/` so the magnotia-design skill (which runs from this repo) can read ground-truth Svelte source from `magnotia-source/` (a sibling import). The runtime build does not include this folder; it is a reference root. +The design system files sit under `src/` so the lumotia-design skill (which runs from this repo) can read ground-truth Svelte source from `lumotia-source/` (a sibling import). The runtime build does not include this folder; it is a reference root. ## Watch outs diff --git a/docs/architecture-map/01-frontend/frontend-tauri-bridge.md b/docs/architecture-map/01-frontend/frontend-tauri-bridge.md index 57d887a..e9f0760 100644 --- a/docs/architecture-map/01-frontend/frontend-tauri-bridge.md +++ b/docs/architecture-map/01-frontend/frontend-tauri-bridge.md @@ -15,8 +15,8 @@ last_verified: 2026/05/09 - **Direction in (Rust → frontend):** events listed under "Tauri events listened to". - **Direction out (frontend → Rust):** all `invoke()` commands listed under "Tauri commands invoked". -- **DOM-only events:** `magnotia:*` `CustomEvent`s on `window`. These never cross the Rust boundary. -- **Cross-window event:** `magnotia:preferences-changed` is the only one that goes through `emit()`. +- **DOM-only events:** `lumotia:*` `CustomEvent`s on `window`. These never cross the Rust boundary. +- **Cross-window event:** `lumotia:preferences-changed` is the only one that goes through `emit()`. ## Tauri commands invoked (alphabetical) @@ -91,10 +91,10 @@ Plus any commands invoked by `saveMarkdown.ts` via the dialog/file-write plugin | Event | Listener | |---|---| -| `magnotia:hotkey-pressed` | `routes/+layout.svelte:177` (evdev backend) | -| `magnotia:llm-download-progress` | `pages/SettingsPage.svelte:873` | -| `magnotia:open-wind-down` | `routes/+layout.svelte:251` (tray menu hook) | -| `magnotia:preferences-changed` | `routes/+layout.svelte:263`, `routes/float/+layout@.svelte`, `routes/viewer/+layout@.svelte`, `routes/preview/+layout@.svelte:41` | +| `lumotia:hotkey-pressed` | `routes/+layout.svelte:177` (evdev backend) | +| `lumotia:llm-download-progress` | `pages/SettingsPage.svelte:873` | +| `lumotia:open-wind-down` | `routes/+layout.svelte:251` (tray menu hook) | +| `lumotia:preferences-changed` | `routes/+layout.svelte:263`, `routes/float/+layout@.svelte`, `routes/viewer/+layout@.svelte`, `routes/preview/+layout@.svelte:41` | | `model-download-progress` | `pages/SettingsPage.svelte:864`, `pages/FirstRunPage.svelte:46`, `components/ModelDownloader.svelte:31` | | `parakeet-download-progress` | `pages/SettingsPage.svelte:880`, `pages/FirstRunPage.svelte:50` | | `preview-cleanup` | `routes/preview/+page.svelte:141` | @@ -109,28 +109,28 @@ Plus any commands invoked by `saveMarkdown.ts` via the dialog/file-write plugin | Event | Emitter | Purpose | |---|---|---| -| `magnotia:preferences-changed` | `stores/preferences.svelte.ts` (`broadcastPreferences`) | Cross-window preference sync. | +| `lumotia:preferences-changed` | `stores/preferences.svelte.ts` (`broadcastPreferences`) | Cross-window preference sync. | | `preview-append` | `pages/DictationPage.svelte:165` | Stream partial text to overlay window. | | `preview-listening` | `pages/DictationPage.svelte:381` | Tell overlay to enter listening state. | | `preview-cleanup` | `pages/DictationPage.svelte:531` | Tell overlay to enter cleanup state. | | `preview-final` | `pages/DictationPage.svelte:539` | Tell overlay to render final text. | | `preview-hide` | `pages/DictationPage.svelte:606` | Tell overlay to dismiss. | -## DOM-only `magnotia:*` window events (intra-frontend bus) +## DOM-only `lumotia:*` window events (intra-frontend bus) | Event | Dispatcher | Listener(s) | |---|---|---| -| `magnotia:focus-timer-cancelled` | `stores/focusTimer.svelte.ts` | `completionStats` (indirectly), components subscribed to focus timer. | -| `magnotia:focus-timer-complete` | `stores/focusTimer.svelte.ts` | Same as above. | -| `magnotia:implementation-rules-changed` | `stores/implementationIntentions.svelte.ts` | `ImplementationRulesEditor`. | -| `magnotia:microstep-generated` | `stores/nudgeBus.svelte.ts` (around micro step generation) | `MicroSteps.svelte`. | -| `magnotia:morning-triage-finished` | `MorningTriageModal.svelte` | `nudgeBus`. | -| `magnotia:start-timer` | `WipTaskList.svelte`, `MicroSteps.svelte` | `FocusTimer.svelte` + `focusTimer` store. | -| `magnotia:step-completed` | `MicroSteps.svelte` | `completionStats` refresh. | -| `magnotia:task-completed` | `stores/page.svelte.ts` (`completeTask`) | `completionStats`. | -| `magnotia:task-deleted` | `stores/page.svelte.ts` (`deleteTask`) | `completionStats`. | -| `magnotia:task-uncompleted` | `stores/page.svelte.ts` (`uncompleteTask`) | `completionStats`. | -| `magnotia:toggle-recording` | `routes/+layout.svelte` (after hotkey debounce, both backends) | `pages/DictationPage.svelte`. | +| `lumotia:focus-timer-cancelled` | `stores/focusTimer.svelte.ts` | `completionStats` (indirectly), components subscribed to focus timer. | +| `lumotia:focus-timer-complete` | `stores/focusTimer.svelte.ts` | Same as above. | +| `lumotia:implementation-rules-changed` | `stores/implementationIntentions.svelte.ts` | `ImplementationRulesEditor`. | +| `lumotia:microstep-generated` | `stores/nudgeBus.svelte.ts` (around micro step generation) | `MicroSteps.svelte`. | +| `lumotia:morning-triage-finished` | `MorningTriageModal.svelte` | `nudgeBus`. | +| `lumotia:start-timer` | `WipTaskList.svelte`, `MicroSteps.svelte` | `FocusTimer.svelte` + `focusTimer` store. | +| `lumotia:step-completed` | `MicroSteps.svelte` | `completionStats` refresh. | +| `lumotia:task-completed` | `stores/page.svelte.ts` (`completeTask`) | `completionStats`. | +| `lumotia:task-deleted` | `stores/page.svelte.ts` (`deleteTask`) | `completionStats`. | +| `lumotia:task-uncompleted` | `stores/page.svelte.ts` (`uncompleteTask`) | `completionStats`. | +| `lumotia:toggle-recording` | `routes/+layout.svelte` (after hotkey debounce, both backends) | `pages/DictationPage.svelte`. | ## Window APIs @@ -151,8 +151,8 @@ Plus any commands invoked by `saveMarkdown.ts` via the dialog/file-write plugin ## Watch outs - The truncated grep returned `tts_s...` as a unique prefix. Verify every TTS command name (`tts_speak`, possibly `tts_stop`) against `lib.rs` to make sure the table above is exhaustive. -- The "preview-*" events have two flavours: `magnotia:` namespaced (`preferences-changed`) and bare (`preview-listening`, `preview-cleanup`, `preview-hide`, `preview-append`, `preview-final`). The bare names are scoped to the overlay window's two-way handshake. Do not rename. -- `magnotia:open-wind-down` listener is in `+layout.svelte` so the page opens regardless of which window the tray click came from. If you ever isolate windows further, this assumption breaks. +- The "preview-*" events have two flavours: `lumotia:` namespaced (`preferences-changed`) and bare (`preview-listening`, `preview-cleanup`, `preview-hide`, `preview-append`, `preview-final`). The bare names are scoped to the overlay window's two-way handshake. Do not rename. +- `lumotia:open-wind-down` listener is in `+layout.svelte` so the page opens regardless of which window the tray click came from. If you ever isolate windows further, this assumption breaks. - `task-window-focus` is the only event sent specifically to the float window. - DOM `CustomEvent` handlers do not survive across windows (they are window-local). The tasks list refresh trick on focus relies on this. diff --git a/docs/architecture-map/01-frontend/i18n.md b/docs/architecture-map/01-frontend/i18n.md index 16fc971..e176b05 100644 --- a/docs/architecture-map/01-frontend/i18n.md +++ b/docs/architecture-map/01-frontend/i18n.md @@ -38,7 +38,7 @@ export const SUPPORTED_LOCALES: { code: Locale; label: string }[] = [ { code: "de", label: "Deutsch" }, ]; -const STORAGE_KEY = "magnotia_locale"; +const STORAGE_KEY = "lumotia_locale"; register("en", () => import("./locales/en.json")); register("es", () => import("./locales/es.json")); @@ -49,7 +49,7 @@ function detectInitialLocale(): Locale { /* localStorage → navigator.language Public exports: - `initI18n()`. Idempotent. Called from every layout's `onMount`-ish path (`+layout.svelte:38` and the float/viewer/preview break layouts). -- `setLocale(code)`. Writes to `magnotia_locale` and updates the svelte-i18n store. +- `setLocale(code)`. Writes to `lumotia_locale` and updates the svelte-i18n store. - `currentLocale`. A derived store that components subscribe to via `$currentLocale`. - `SUPPORTED_LOCALES` constant for the picker. @@ -65,7 +65,7 @@ Public exports: ## Watch outs - Adding a new locale is one `register("xx", () => import("./locales/xx.json"))` call plus a `SUPPORTED_LOCALES` entry. -- Cross window: each window initialises i18n independently via its layout. Locale change persists to `localStorage["magnotia_locale"]`. Float and viewer pick up the new locale on the next mount, not live. Worth wiring a Tauri event if live cross-window translation is needed. +- Cross window: each window initialises i18n independently via its layout. Locale change persists to `localStorage["lumotia_locale"]`. Float and viewer pick up the new locale on the next mount, not live. Worth wiring a Tauri event if live cross-window translation is needed. - The "British English" toggle in `settings.britishEnglish` is a transcription post-processing flag (slice 04 territory), not a UI locale. Not wired through svelte-i18n. - Default locale is detected from `navigator.language`. In Tauri, this is the OS locale. diff --git a/docs/architecture-map/01-frontend/pages-overview.md b/docs/architecture-map/01-frontend/pages-overview.md index cca56b8..43f97a8 100644 --- a/docs/architecture-map/01-frontend/pages-overview.md +++ b/docs/architecture-map/01-frontend/pages-overview.md @@ -9,7 +9,7 @@ last_verified: 2026/05/09 > **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Pages overview -**Plain English summary.** Magnotia has seven main window pages and three secondary window pages. The main window picks which to show by reading `page.current` (a string in the `page` store) and rendering the matching component. The secondary windows are routed by URL (`/float`, `/viewer`, `/preview`). +**Plain English summary.** Lumotia has seven main window pages and three secondary window pages. The main window picks which to show by reading `page.current` (a string in the `page` store) and rendering the matching component. The secondary windows are routed by URL (`/float`, `/viewer`, `/preview`). ## At a glance @@ -31,7 +31,7 @@ last_verified: 2026/05/09 | Tasks | `src/lib/pages/TasksPage.svelte` | 725 | Inbox/today/soon/later board with energy chips, lists, completion sparkline. | | Files | `src/lib/pages/FilesPage.svelte` | 263 | Drop or browse audio/video files. Calls `transcribe_file`. | | First run | `src/lib/pages/FirstRunPage.svelte` | 337 | Hardware probe (`probe_system`), model recommendation, model download. Auto exits to dictation. | -| Shutdown ritual | `src/lib/pages/ShutdownRitualPage.svelte` | 169 | Evening wind down ritual. Triggered from the tray menu via `magnotia:open-wind-down`. | +| Shutdown ritual | `src/lib/pages/ShutdownRitualPage.svelte` | 169 | Evening wind down ritual. Triggered from the tray menu via `lumotia:open-wind-down`. | ### Secondary windows (URL routed) diff --git a/docs/architecture-map/01-frontend/pages/dictation.md b/docs/architecture-map/01-frontend/pages/dictation.md index eb7176e..6f37c85 100644 --- a/docs/architecture-map/01-frontend/pages/dictation.md +++ b/docs/architecture-map/01-frontend/pages/dictation.md @@ -39,12 +39,12 @@ last_verified: 2026/05/09 ### Lifecycle -- `onMount`. Loads `runtimeCapabilities` (`DictationPage.svelte:134`). Sets up the global hotkey custom event listener (`magnotia:toggle-recording`, dispatched from the `+layout.svelte` hotkey path). +- `onMount`. Loads `runtimeCapabilities` (`DictationPage.svelte:134`). Sets up the global hotkey custom event listener (`lumotia:toggle-recording`, dispatched from the `+layout.svelte` hotkey path). - `onDestroy`. Tears down listeners and timers. ### Recording flow (high level) -1. Toggle from the mic button or the `magnotia:toggle-recording` window event. +1. Toggle from the mic button or the `lumotia:toggle-recording` window event. 2. If model not ready, ensure model: check `check_engine`, `check_parakeet_engine`, `check_llm_model`, then load via `load_model` / `load_parakeet_model` / `load_llm_model` as required (`DictationPage.svelte:241-272`). 3. Open two `Channel` instances (`DictationPage.svelte:341-342`) and call `start_live_transcription_session` with the channel handles. 4. As the backend pushes status and partial result messages, append text to `transcript`, update `segments`, and broadcast `preview-append` to the preview overlay window (`DictationPage.svelte:165, 381, 385`). If the preview is enabled and not already open, `open_preview_window` is invoked. diff --git a/docs/architecture-map/01-frontend/pages/first-run.md b/docs/architecture-map/01-frontend/pages/first-run.md index 7c26b26..ba85f63 100644 --- a/docs/architecture-map/01-frontend/pages/first-run.md +++ b/docs/architecture-map/01-frontend/pages/first-run.md @@ -9,7 +9,7 @@ last_verified: 2026/05/09 > **Where you are:** [Architecture map](../../README.md) → [Frontend](../README.md) → [Pages overview](../pages-overview.md) → First run -**Plain English summary.** What the user sees the first time they launch Magnotia, before any model is on disk. Probes hardware, recommends a Whisper model size, and downloads the chosen model (Whisper or Parakeet). On success, transitions to the dictation page. +**Plain English summary.** What the user sees the first time they launch Lumotia, before any model is on disk. Probes hardware, recommends a Whisper model size, and downloads the chosen model (Whisper or Parakeet). On success, transitions to the dictation page. ## At a glance diff --git a/docs/architecture-map/01-frontend/pages/settings.md b/docs/architecture-map/01-frontend/pages/settings.md index f23993a..0e2ac12 100644 --- a/docs/architecture-map/01-frontend/pages/settings.md +++ b/docs/architecture-map/01-frontend/pages/settings.md @@ -47,13 +47,13 @@ Built from `SettingsGroup` accordions. Top level groups (line refs are anchors i - `audioDevices`, `downloadedModels`, `parakeetOk`, `parakeetDownloaded`, `pasteBackends`, `systemInfo`, `runtimeCapabilities`, `llmLoaded`, `llmModels`, `llmStatuses`, `ttsVoices`. - Search query (`X`/`Search` icons) drives a force open mode for `SettingsGroup` (`SettingsPage.svelte:432-477`). -- Three concurrent download progress listeners: Whisper (`model-download-progress`), Parakeet (`parakeet-download-progress`), LLM (`magnotia:llm-download-progress`) (`SettingsPage.svelte:864-880`). +- Three concurrent download progress listeners: Whisper (`model-download-progress`), Parakeet (`parakeet-download-progress`), LLM (`lumotia:llm-download-progress`) (`SettingsPage.svelte:864-880`). ## Tauri command surface Audio: `list_audio_devices`. Models (Whisper): `list_models`, `download_model`, `load_model`, `check_engine`. Models (Parakeet): `check_parakeet_engine`, `check_parakeet_model`, `download_parakeet_model`, `load_parakeet_model`. Models (LLM): `recommend_llm_tier`, `check_llm_model`, `download_llm_model`, `load_llm_model`, `unload_llm_model`, `delete_llm_model`, `test_llm_model`, `get_llm_status`. Capabilities: `get_runtime_capabilities`, `probe_system`, `detect_paste_backends`. TTS: `tts_list_voices`, `tts_speak`. Diagnostics: `generate_diagnostic_report`, `save_diagnostic_report`. Plus the implicit `save_preferences` invoked by the preferences store on every accessibility toggle. -Events listened to: `model-download-progress`, `parakeet-download-progress`, `magnotia:llm-download-progress`. +Events listened to: `model-download-progress`, `parakeet-download-progress`, `lumotia:llm-download-progress`. ## Watch outs diff --git a/docs/architecture-map/01-frontend/pages/tasks.md b/docs/architecture-map/01-frontend/pages/tasks.md index defd297..e0d3c0a 100644 --- a/docs/architecture-map/01-frontend/pages/tasks.md +++ b/docs/architecture-map/01-frontend/pages/tasks.md @@ -43,7 +43,7 @@ last_verified: 2026/05/09 ### Events -- Listens implicitly to: `magnotia:task-completed`, `magnotia:task-uncompleted`, `magnotia:task-deleted`, `magnotia:step-completed` are emitted from store helpers and consumed by `completionStats.svelte.ts` to refresh the sparkline. +- Listens implicitly to: `lumotia:task-completed`, `lumotia:task-uncompleted`, `lumotia:task-deleted`, `lumotia:step-completed` are emitted from store helpers and consumed by `completionStats.svelte.ts` to refresh the sparkline. ## Tauri command surface (direct) @@ -54,7 +54,7 @@ The store helpers used here call into Rust through `add_task_cmd`, `complete_tas ## Watch outs - Drag and drop is heavy on this page. Reorder mutations should always go via the store helpers; do not manipulate `tasks` directly or you will desync the SQLite source of truth. -- The sparkline is gated on `settings.showMomentumSparkline` (true by default). It re renders on the four `magnotia:task-*` events, plus window focus for date rollover. +- The sparkline is gated on `settings.showMomentumSparkline` (true by default). It re renders on the four `lumotia:task-*` events, plus window focus for date rollover. - "Pop out" deliberately does not pass any state. The float window reads the same store, which is shared via store hydration on mount and `localStorage` for `settings`. - Sort, filter, search are not persisted. Reload returns to defaults. diff --git a/docs/architecture-map/01-frontend/stores.md b/docs/architecture-map/01-frontend/stores.md index 6038668..99bbb1f 100644 --- a/docs/architecture-map/01-frontend/stores.md +++ b/docs/architecture-map/01-frontend/stores.md @@ -9,7 +9,7 @@ last_verified: 2026/05/09 > **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Stores -**Plain English summary.** Reactive state. Magnotia uses Svelte 5 runes (`$state`, `$derived`, `$effect`) instead of legacy stores. Each file under `src/lib/stores/` exports one (or more) `$state` objects plus the helper functions that read or mutate them. Components import the state directly and Svelte tracks reactivity automatically. Two persistence channels: `localStorage` for settings, profiles, task lists, templates; Tauri (`save_preferences`) for accessibility preferences. +**Plain English summary.** Reactive state. Lumotia uses Svelte 5 runes (`$state`, `$derived`, `$effect`) instead of legacy stores. Each file under `src/lib/stores/` exports one (or more) `$state` objects plus the helper functions that read or mutate them. Components import the state directly and Svelte tracks reactivity automatically. Two persistence channels: `localStorage` for settings, profiles, task lists, templates; Tauri (`save_preferences`) for accessibility preferences. ## At a glance @@ -17,8 +17,8 @@ last_verified: 2026/05/09 - **Files:** 10 (4 085 LOC across stores + utils + types). - **Cross window sync:** - `localStorage` `storage` event in the float window for settings. - - Tauri `emit("magnotia:preferences-changed")` for preferences (handled by every layout). -- **Persistence keys:** `magnotia_settings`, `magnotia_profiles`, `magnotia_task_lists`, `magnotia_templates`, `magnotia_locale`, plus a viewer handoff key. + - Tauri `emit("lumotia:preferences-changed")` for preferences (handled by every layout). +- **Persistence keys:** `lumotia_settings`, `lumotia_profiles`, `lumotia_task_lists`, `lumotia_templates`, `lumotia_locale`, plus a viewer handoff key. ## The stores @@ -26,7 +26,7 @@ last_verified: 2026/05/09 Owns: - `page` (`PageState`): `current`, `status`, `statusColor`, `activeProfile`, `recording`, `timerText`, `handedness`, `taskSidebarOpen`. Initial: `current = "dictation"`. -- `settings` (`SettingsState`). Persisted to `localStorage["magnotia_settings"]` via `utils/settingsMigrations.ts`. Defaults at line 60 onward. Includes engine choice, model size, language, device, format mode, fillers, anti hallucination, British English, auto copy/paste, transcription preview, meeting auto capture (+ apps list), sound cues + volume, timestamps, theme, font size, AI tier, LLM model id and prompt preset, GPU concurrency, prewarm flag, save audio, output folder, global hotkey, sidebar collapsed, microphone device, energy state, match my energy, TTS voice + rate, rituals (morning/evening + time), launch at login, ritualsPromptSeen, nudges (enabled/muted/speakAloud), showMomentumSparkline. +- `settings` (`SettingsState`). Persisted to `localStorage["lumotia_settings"]` via `utils/settingsMigrations.ts`. Defaults at line 60 onward. Includes engine choice, model size, language, device, format mode, fillers, anti hallucination, British English, auto copy/paste, transcription preview, meeting auto capture (+ apps list), sound cues + volume, timestamps, theme, font size, AI tier, LLM model id and prompt preset, GPU concurrency, prewarm flag, save audio, output folder, global hotkey, sidebar collapsed, microphone device, energy state, match my energy, TTS voice + rate, rituals (morning/evening + time), launch at login, ritualsPromptSeen, nudges (enabled/muted/speakAloud), showMomentumSparkline. - `profiles`, `templates`, `taskLists` arrays. - `tasks` and `history` arrays. @@ -34,7 +34,7 @@ Helpers (selected, with line refs): - `addToHistory` → `add_transcript` (`page.svelte.ts:234`). - `mapTranscriptRow`, `saveTranscriptMeta` → `update_transcript` (`page.svelte.ts:272`). - `deleteFromHistoryById` → `delete_transcript` (`page.svelte.ts:320`). -- `addTask`, `completeTask`, `uncompleteTask`, `deleteTask`, `setTaskEnergy`, `updateTask` → matching `*_task_cmd` Rust commands. `completeTask` and friends emit `magnotia:task-completed | -uncompleted | -deleted` window events (line 470 onward). +- `addTask`, `completeTask`, `uncompleteTask`, `deleteTask`, `setTaskEnergy`, `updateTask` → matching `*_task_cmd` Rust commands. `completeTask` and friends emit `lumotia:task-completed | -uncompleted | -deleted` window events (line 470 onward). - Task lists: `addTaskList`, `renameTaskList`, `deleteTaskList`, `moveTaskList`, `sortTaskLists`, `moveTaskToList`, `moveTaskListToProfile`. - Profile mutators: `addProfileTaskList`, `removeProfileTaskList`. - `saveSettings`, `saveProfiles`, `saveTemplates` write to `localStorage`. @@ -45,7 +45,7 @@ Owns the `Preferences` shape: `theme` ("light" | "dark" | "system"), `zone` ("de - DOM is the source of truth at runtime: `readFromDOM()` reads `` data attributes and CSS variables; `applyToDOM(prefs)` writes them. - Persists via `invoke("save_preferences", { preferences: JSON.stringify(prefs) })` with a debounce. Failure shows a single toast per process (`preferences.svelte.ts:101-120`). -- Cross window: `broadcastPreferences` calls `emit("magnotia:preferences-changed", { source, prefs })`. Receivers in `+layout.svelte` and the secondary `+layout@.svelte` files apply external prefs after a label check. +- Cross window: `broadcastPreferences` calls `emit("lumotia:preferences-changed", { source, prefs })`. Receivers in `+layout.svelte` and the secondary `+layout@.svelte` files apply external prefs after a label check. - Exposes `getPreferences`, `updatePreferences`, `updateAccessibility`, `applyExternalPreferences`, `PREFERENCES_CHANGED_EVENT` constant. - Font families resolved from a constant map (Lexend, Atkinson, OpenDyslexic). @@ -74,14 +74,14 @@ Tracks the LLM lifecycle pill. State is one of `idle | loading | generating | do Phase 8 gamification. Owns `recentCompletions` (`DailyCompletionCount[]`) and a `todayCount` derivation. -Refresh triggers (no polling): module load, `magnotia:task-completed`, `magnotia:step-completed`, `magnotia:task-uncompleted`, `magnotia:task-deleted`, window focus (for midnight rollover). +Refresh triggers (no polling): module load, `lumotia:task-completed`, `lumotia:step-completed`, `lumotia:task-uncompleted`, `lumotia:task-deleted`, window focus (for midnight rollover). ### `focusTimer.svelte.ts` (238 LOC). Owns the floating focus timer. State: target ms, started at, label, taskId, paused. - `setInterval` at 250 ms (`TICK_INTERVAL_MS`). -- Triggered by `magnotia:start-timer` window events. Emits `magnotia:focus-timer-cancelled` and `magnotia:focus-timer-complete` on transitions. +- Triggered by `lumotia:start-timer` window events. Emits `lumotia:focus-timer-cancelled` and `lumotia:focus-timer-complete` on transitions. ### `implementationIntentions.svelte.ts` (260 LOC). @@ -90,7 +90,7 @@ Owns the floating focus timer. State: target ms, started at, label, taskId, paus - 30 second `setInterval` (`TIME_RULE_POLL_MS`) checks time based rules. - On match, can route `page.current = "tasks"` (lines 125, 136, 142) and call `tts_speak` if speak aloud is enabled (line 170). - Uses `delete_implementation_rule` to remove a rule (line 82). -- Emits `magnotia:implementation-rules-changed` after writes. +- Emits `lumotia:implementation-rules-changed` after writes. - Lifecycle: `startImplementationIntentions`, `stopImplementationIntentions` from `+layout.svelte`. ### `nudgeBus.svelte.ts` (292 LOC). @@ -101,7 +101,7 @@ Owns the nudge engine. Two `setInterval` handles: - Plus a `microStepTimers` Map for per task delayed notifications. Key commands: `deliver_nudge` (line 112), `tts_speak` (line 119). -Emits `magnotia:morning-triage-finished` after the modal closes. +Emits `lumotia:morning-triage-finished` after the modal closes. Lifecycle: `startNudgeBus`, `stopNudgeBus` from `+layout.svelte`. ### `speaker.svelte.ts` (10 LOC). @@ -111,9 +111,9 @@ Tiny. Holds `speakingId` so `SpeakerButton` instances can debounce / cancel each ## Cross store traffic - `DictationPage.completeRecording()` → `addToHistory` (page) → `add_transcript` Rust → push to history. -- `MicroSteps` "start timer" button → `magnotia:start-timer` → `focusTimer` store transitions → `FocusTimer` component renders ring. -- `TasksPage.addTask` → `tasks` store → emits `magnotia:task-completed/...` → `completionStats` refreshes → `CompletionSparkline` re renders. -- `SettingsPage` accessibility toggle → `updatePreferences` → DOM write + `save_preferences` invoke + `magnotia:preferences-changed` emit → secondary windows mirror. +- `MicroSteps` "start timer" button → `lumotia:start-timer` → `focusTimer` store transitions → `FocusTimer` component renders ring. +- `TasksPage.addTask` → `tasks` store → emits `lumotia:task-completed/...` → `completionStats` refreshes → `CompletionSparkline` re renders. +- `SettingsPage` accessibility toggle → `updatePreferences` → DOM write + `save_preferences` invoke + `lumotia:preferences-changed` emit → secondary windows mirror. - Float window settings sync uses `localStorage` `storage` event, not the Tauri preference event. Drift candidate. ## Watch outs @@ -121,7 +121,7 @@ Tiny. Holds `speakingId` so `SpeakerButton` instances can debounce / cancel each - `page.svelte.ts` is doing a lot. Splitting transcripts, tasks, task lists, settings, profiles into separate stores would help but is outside this map's job. - `settings` and `preferences` overlap (theme, font size, `transcriptSize`). Settings are localStorage only; preferences are Tauri persisted. Race possible during the migration `$effect`. - `nudgeBus` and `implementationIntentions` both run timers. They are torn down in `+layout.svelte` `onDestroy`. Only the main window starts them. -- `completionStats` listens on the DOM `window` for events; it does not subscribe to `tasks` directly. Adding a new task mutation that does not emit one of the `magnotia:task-*` events will silently break the sparkline. +- `completionStats` listens on the DOM `window` for events; it does not subscribe to `tasks` directly. Adding a new task mutation that does not emit one of the `lumotia:task-*` events will silently break the sparkline. ## See also diff --git a/docs/architecture-map/01-frontend/windows-and-routes.md b/docs/architecture-map/01-frontend/windows-and-routes.md index 93c494e..3835a17 100644 --- a/docs/architecture-map/01-frontend/windows-and-routes.md +++ b/docs/architecture-map/01-frontend/windows-and-routes.md @@ -9,7 +9,7 @@ last_verified: 2026/05/09 > **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Windows and routes -**Plain English summary.** Magnotia is a single SvelteKit build that runs as multiple desktop windows. The Rust side opens four webviews (main, tasks float, transcript viewer, transcription preview) and points each one at a different SvelteKit route. The frontend uses SvelteKit's `+layout@.svelte` "break" trick so secondary windows skip the main shell (sidebar, titlebar, toast viewport) and render a focused, single purpose UI. +**Plain English summary.** Lumotia is a single SvelteKit build that runs as multiple desktop windows. The Rust side opens four webviews (main, tasks float, transcript viewer, transcription preview) and points each one at a different SvelteKit route. The frontend uses SvelteKit's `+layout@.svelte` "break" trick so secondary windows skip the main shell (sidebar, titlebar, toast viewport) and render a focused, single purpose UI. ## At a glance @@ -45,7 +45,7 @@ Responsibilities: - Detect Tauri runtime (`hasTauriRuntime`) and OS (`loadOsInfo`). Linux uses native KWin/Mutter decorations; macOS and Windows render the custom `Titlebar` plus invisible `ResizeHandles`. Default `useCustomChrome = false` to avoid a flash on Linux (`+layout.svelte:49`). - Hotkey backend selection. On Wayland, attempt the evdev backend (`check_hotkey_access`). Otherwise fall back to `tauri-plugin-global-shortcut`. Falls back to "unavailable" if neither path works (`+layout.svelte:76-102`). - Hotkey registration. Only the `main` window owns the global shortcut. The function debounces evdev autorepeat at 120 ms (Handy issue #1143 referenced in comments, `+layout.svelte:171-186`). -- Cross window listeners: `magnotia:hotkey-pressed` (evdev path), `magnotia:open-wind-down` (tray menu), `magnotia:preferences-changed` (sync prefs across windows). Apply external preferences if the source label differs from our own. +- Cross window listeners: `lumotia:hotkey-pressed` (evdev path), `lumotia:open-wind-down` (tray menu), `lumotia:preferences-changed` (sync prefs across windows). Apply external preferences if the source label differs from our own. - Theme migration `$effect`. Reads `settings.theme` (legacy "Light" / "Dark" / "System") and writes `preferences.theme` ("light" / "dark" / "system"). See README debt note 2. - Font size CSS variable `--font-size-transcript` set on `` from `settings.fontSize`. - Global error capture. `window.onerror` and `unhandledrejection` forward to `log_frontend_error` Rust command. Best effort, swallow throws. @@ -80,11 +80,11 @@ Tasks float window. Self contained quick add, list filter, sort menu, completed ### `src/routes/viewer/+layout@.svelte` (77 LOC) -Same break layout pattern. Mounts `Titlebar`, `ToastViewport`, applies preferences sync via `magnotia:preferences-changed`, applies theme. +Same break layout pattern. Mounts `Titlebar`, `ToastViewport`, applies preferences sync via `lumotia:preferences-changed`, applies theme. ### `src/routes/viewer/+page.svelte` (606 LOC) -Transcript editor. Hydrates from a transcript ID handed off via `localStorage` (`magnotia:viewer-handoff` style key, see file). Fetches the full row from SQLite via `get_transcript`. Renders an `
@@ -256,11 +353,8 @@

Downloading model

{downloadingModel}

-
-
+
+

{downloadProgress}%

{#if estimatedMinutes > 2} @@ -269,12 +363,26 @@
{:else} +
-

Welcome to Magnotia

+

Welcome to Lumotia

Press the button. Start talking. That's it.

{#if error} -
{error}
+ +
+ +

{error}

+
+ { error = ""; probe(); }}> + Try again + + + Skip this step + +
+
+
{/if} {#if systemInfo} diff --git a/src/lib/pages/HistoryPage.svelte b/src/lib/pages/HistoryPage.svelte index 2522e8e..f6eb36f 100644 --- a/src/lib/pages/HistoryPage.svelte +++ b/src/lib/pages/HistoryPage.svelte @@ -22,8 +22,9 @@ import { clampTextLines, measurePreWrap } from "$lib/utils/textMeasure.js"; import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; import { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js"; - import Card from "$lib/components/Card.svelte"; - import EmptyState from "$lib/components/EmptyState.svelte"; + import LumotiaCard from "$lib/ui/LumotiaCard.svelte"; + import LumotiaEmptyState from "$lib/ui/LumotiaEmptyState.svelte"; + import LumotiaButton from "$lib/ui/LumotiaButton.svelte"; import { formatTime, formatDuration } from "$lib/utils/time.js"; import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js"; import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag } from 'lucide-svelte'; @@ -50,6 +51,79 @@ let showStarredOnly = $state(false); let activeTagFilter = $state(null); // null = all; string = tag value let expandedId = $state(null); + + // View toggle: "live" shows current transcripts (default), "trash" + // shows soft-deleted transcripts. Backed by `list_trashed_transcripts` + // and `restore_transcript` Tauri commands. Permanent-delete-from-trash + // is intentionally deferred — the 30-day startup purge + // (TRANSCRIPT_TRASH_RETENTION_DAYS in src-tauri/src/lib.rs) handles + // hard-removal until the UI for it lands. + let viewMode: "live" | "trash" = $state("live"); + let trashItems = $state([]); + let trashLoading = $state(false); + let trashError = $state(""); + let restoringId = $state(null); + + async function loadTrash() { + trashLoading = true; + trashError = ""; + try { + trashItems = await invoke("list_trashed_transcripts", { limit: 500, offset: 0 }); + } catch (err) { + trashError = typeof err === "string" ? err : String(err); + toasts.error("Failed to load Trash", trashError); + } finally { + trashLoading = false; + } + } + + async function restoreFromTrash(id) { + if (restoringId) return; + restoringId = id; + try { + await invoke("restore_transcript", { id: String(id) }); + // Drop the row from the trash list. The live list will pick the + // restored row up on next refresh; we deliberately do not splice + // it into `history` directly here, because that store is loaded + // by the page-store init path and inserting a partial DTO would + // drift the in-memory shape from the SQLite source of truth. + trashItems = trashItems.filter((t) => t.id !== id); + } catch (err) { + toasts.error( + "Restore failed", + typeof err === "string" ? err : String(err), + ); + } finally { + restoringId = null; + } + } + + // Formats an ISO timestamp for display in the Trash list. We surface + // the transcript's `createdAt` rather than the deletion timestamp + // because `deleted_at` is not currently in the TranscriptDto shape; + // adding it is out of scope for this fix. Falls back to the raw + // string if Date can't parse it so we never display "NaN". + function formatTrashTimestamp(iso) { + if (!iso) return ""; + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); + } catch { + return iso; + } + } + + // Switching to trash view loads the list once. Switching back to live + // discards the trash data so a stale list doesn't reappear on toggle. + $effect(() => { + if (viewMode === "trash") { + void loadTrash(); + } else { + trashItems = []; + trashError = ""; + } + }); // Phase 9 bulk selection. Selection state is frontend-only and resets // on page refresh by design. let selected = $state(new Set()); @@ -70,21 +144,45 @@ // user who walked away doesn't return to a primed delete button. // Replaces the native window.confirm() dialog, which broke the warm // UI tone with an OS-styled modal. + // + // NOTE: clearAll (delete-everything) is gated by a stronger + // type-the-word-DELETE modal — see CLEAR_ALL_CONFIRM_WORD below. + // The arm-confirm pattern is retained for bulkDelete (selection + // subset) where the failure mode is smaller. const CONFIRM_TIMEOUT_MS = 4000; - let clearAllArmed = $state(false); let bulkDeleteArmed = $state(false); - let clearAllArmTimer: ReturnType | null = null; let bulkDeleteArmTimer: ReturnType | null = null; - function armClearAll() { - clearAllArmed = true; - if (clearAllArmTimer) clearTimeout(clearAllArmTimer); - clearAllArmTimer = setTimeout(() => { clearAllArmed = false; }, CONFIRM_TIMEOUT_MS); + // Type-the-word modal state for clearAll. Replaces the 4-second + // arm-confirm because Clear All wipes every transcript at once; + // mis-clicks under the prior pattern soft-deleted the entire + // history. The user must type the word DELETE exactly + // (case-sensitive) before the Confirm button activates. + const CLEAR_ALL_CONFIRM_WORD = "DELETE"; + let clearAllModalOpen = $state(false); + let clearAllConfirmInput = $state(""); + let clearAllInputEl = $state(null); + let clearAllInProgress = $state(false); + let clearAllConfirmValid = $derived( + clearAllConfirmInput === CLEAR_ALL_CONFIRM_WORD, + ); + + function openClearAllModal() { + clearAllConfirmInput = ""; + clearAllModalOpen = true; + // Focus the input on the next tick — Svelte 5 binds run after the + // {#if} branch mounts, so a microtask is enough. + queueMicrotask(() => { + if (clearAllInputEl) clearAllInputEl.focus(); + }); } - function disarmClearAll() { - clearAllArmed = false; - if (clearAllArmTimer) { clearTimeout(clearAllArmTimer); clearAllArmTimer = null; } + + function closeClearAllModal() { + if (clearAllInProgress) return; + clearAllModalOpen = false; + clearAllConfirmInput = ""; } + function armBulkDelete() { bulkDeleteArmed = true; if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer); @@ -100,16 +198,13 @@ // this derived feeds an aria-live region near the page root so the // armed state is announced as it arms and disarms. let confirmAnnouncement = $derived( - clearAllArmed - ? "Clear all armed. Click confirm to delete all history, or cancel to dismiss." - : bulkDeleteArmed - ? `Delete ${selected.size} ${selected.size === 1 ? 'transcript' : 'transcripts'} armed. Click confirm or cancel to dismiss.` - : "" + bulkDeleteArmed + ? `Delete ${selected.size} ${selected.size === 1 ? 'transcript' : 'transcripts'} armed. Click confirm or cancel to dismiss.` + : "" ); onDestroy(() => { stopPlayback(); - if (clearAllArmTimer) clearTimeout(clearAllArmTimer); if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer); }); @@ -273,8 +368,13 @@ return items; }); + // Wipe every live transcript. Soft-delete via the SQLite backend so + // the user has a 30-day window to restore from Trash. Gated behind + // the type-the-word-DELETE modal — the prior 4-second arm-confirm + // let a single mis-click destroy the whole history. async function clearAll() { - disarmClearAll(); + if (!clearAllConfirmValid || clearAllInProgress) return; + clearAllInProgress = true; const ids = history.map((entry) => entry.id); history.splice(0); expandedId = null; @@ -294,6 +394,9 @@ `${failed} of ${ids.length} failed to delete from disk and may reappear after restart.`, ); } + clearAllInProgress = false; + clearAllModalOpen = false; + clearAllConfirmInput = ""; } function toggleExpand(id) { @@ -387,12 +490,12 @@ // script. const handoff = JSON.stringify({ id: String(item.id) }); try { - localStorage.setItem("magnotia_viewer_item", handoff); - localStorage.setItem("magnotia_viewer_mode", "view"); + localStorage.setItem("lumotia_viewer_item", handoff); + localStorage.setItem("lumotia_viewer_mode", "view"); await invoke("open_viewer_window"); } catch { - localStorage.setItem("magnotia_viewer_item", handoff); - localStorage.setItem("magnotia_viewer_mode", "view"); + localStorage.setItem("lumotia_viewer_item", handoff); + localStorage.setItem("lumotia_viewer_mode", "view"); window.open("/viewer", "_blank", "width=600,height=700"); } } @@ -551,12 +654,12 @@ // (see openViewer above for the rationale). const handoff = JSON.stringify({ id: String(item.id) }); try { - localStorage.setItem("magnotia_viewer_item", handoff); - localStorage.setItem("magnotia_viewer_mode", "edit"); + localStorage.setItem("lumotia_viewer_item", handoff); + localStorage.setItem("lumotia_viewer_mode", "edit"); await invoke("open_viewer_window"); } catch { - localStorage.setItem("magnotia_viewer_item", handoff); - localStorage.setItem("magnotia_viewer_mode", "edit"); + localStorage.setItem("lumotia_viewer_item", handoff); + localStorage.setItem("lumotia_viewer_mode", "edit"); window.open("/viewer", "_blank", "width=600,height=700"); } } @@ -593,54 +696,147 @@

History

- {history.length} saved -
- - {#if history.some((i) => !i.llmTags || i.llmTags.length === 0)} + +
+
+ {#if viewMode === "live"} + - {/if} - {#if history.length > 0} - {#if clearAllArmed} - Delete all history? This can't be undone. + {#if history.some((i) => !i.llmTags || i.llmTags.length === 0)} - - {:else} + class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50 inline-flex items-center gap-1.5" + onclick={tagAllUntagged} + disabled={bulkTagging} + title="Run AI content tagging across every untagged transcript" + > +
+ + {#if clearAllModalOpen} + + + {/if} + + {#if viewMode === "live"}
- +
-
+
@@ -733,9 +929,9 @@
- + {#if filtered.length === 0} - @@ -969,6 +1165,62 @@
{/if} - + + {:else} + +
+
+ Trashed items are kept for 30 days, then permanently deleted on next app start. +
+
+
+ + {#if trashLoading && trashItems.length === 0} + + {:else if trashError && trashItems.length === 0} + + {:else if trashItems.length === 0} + + {:else} +
+ {#each trashItems as item (item.id)} +
+
+
+ {item.title || (item.text ? item.text.slice(0, 80) : "Untitled")} +
+
+ Created {formatTrashTimestamp(item.createdAt)} +
+
+ +
+ {/each} +
+ {/if} +
+
+ {/if} diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 76a5aec..96a4b5f 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -4,12 +4,13 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { settings, saveSettings, profiles, saveProfiles, templates, saveTemplates, page, addProfileTaskList, removeProfileTaskList } from "$lib/stores/page.svelte.js"; - import Card from "$lib/components/Card.svelte"; - import Toggle from "$lib/components/Toggle.svelte"; + import Card from "$lib/ui/LumotiaCard.svelte"; + import Toggle from "$lib/ui/LumotiaToggle.svelte"; import SegmentedButton from "$lib/components/SegmentedButton.svelte"; import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte"; import ImplementationRulesEditor from "$lib/components/ImplementationRulesEditor.svelte"; - import SettingsGroup from "$lib/components/SettingsGroup.svelte"; + import SettingsGroup from "$lib/ui/LumotiaSettingsGroup.svelte"; + import StatusPill from "$lib/ui/LumotiaStatusPill.svelte"; import ZonePicker from "$lib/components/ZonePicker.svelte"; import AccessibilityControls from "$lib/components/AccessibilityControls.svelte"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; @@ -186,7 +187,7 @@ try { audioDevices = await invoke("list_audio_devices"); } catch (err) { - audioDevicesError = "Could not enumerate audio devices: " + (err?.message || err); + audioDevicesError = String(err?.message ?? err); audioDevices = []; } } @@ -197,6 +198,7 @@ // the invoke payload's initialPrompt is empty. let vocabulary = $state([]); let vocabularyError = $state(null); + let vocabularyErrorDetail = $state(""); let newVocabTerm = $state(""); let newVocabNote = $state(""); let showBulkVocab = $state(false); @@ -214,11 +216,13 @@ async function refreshVocabulary() { vocabularyError = null; + vocabularyErrorDetail = ""; try { const profileId = profilesStore.activeProfileId; vocabulary = await profilesStore.listTerms(profileId); } catch (err) { - vocabularyError = "Could not load vocabulary: " + (err?.message || err); + vocabularyError = "We couldn't load your custom vocabulary. Your existing transcripts are unaffected."; + vocabularyErrorDetail = String(err?.message ?? err); } } @@ -235,7 +239,8 @@ newVocabNote = ""; await refreshVocabulary(); } catch (err) { - vocabularyError = "Could not save term: " + (err?.message || err); + vocabularyError = "Could not save that term. Your other terms are unaffected."; + vocabularyErrorDetail = String(err?.message ?? err); } } @@ -256,7 +261,8 @@ candidates.push(trimmed); } if (candidates.length === 0) { - vocabularyError = "Nothing to import, paste one term per line or separated by commas."; + vocabularyError = "Nothing to import — paste one term per line or separated by commas."; + vocabularyErrorDetail = ""; return; } @@ -268,6 +274,7 @@ bulkVocabBusy = true; vocabularyError = null; + vocabularyErrorDetail = ""; const failed = []; try { for (const term of toAdd) { @@ -291,7 +298,8 @@ if (skipped > 0) parts.push(`skipped ${skipped} duplicate${skipped === 1 ? "" : "s"}`); if (failed.length > 0) parts.push(`${failed.length} failed`); if (failed.length > 0) { - vocabularyError = `Some terms failed: ${failed.map((f) => f.term).join(", ")}`; + vocabularyError = `${failed.length} term${failed.length === 1 ? "" : "s"} couldn't be saved. Your other terms are unaffected.`; + vocabularyErrorDetail = failed.map((f) => `${f.term}: ${f.message}`).join("\n"); } if (parts.length > 0) { toasts.info("Vocabulary import", parts.join(" · ")); @@ -299,11 +307,13 @@ } async function deleteVocabTerm(id) { + if (!confirm("Remove this vocabulary term? Future transcripts won't get the custom spelling. This cannot be undone.")) return; try { await profilesStore.deleteTerm(id); await refreshVocabulary(); } catch (err) { - vocabularyError = "Could not delete term: " + (err?.message || err); + vocabularyError = "We couldn't remove that term. Your other terms are unaffected."; + vocabularyErrorDetail = String(err?.message ?? err); } } @@ -357,6 +367,7 @@ async function deleteActiveProfile() { const active = profilesStore.active; if (!active || active.id === DEFAULT_PROFILE_ID) return; + if (!confirm("Delete the active profile? Its templates, vocabulary, and settings will be removed. This cannot be undone.")) return; await profilesStore.delete(active.id); } @@ -366,11 +377,15 @@ // automatically. let diagnosticReport = $state(""); let diagnosticReportError = $state(null); + let diagnosticReportErrorDetail = $state(""); let diagnosticReportSavedTo = $state(null); let diagnosticReportLoading = $state(false); + let diagnosticBundleLoading = $state(false); + async function generateDiagnosticReport() { diagnosticReportError = null; + diagnosticReportErrorDetail = ""; diagnosticReportSavedTo = null; diagnosticReportLoading = true; try { @@ -383,7 +398,8 @@ }, }); } catch (err) { - diagnosticReportError = "Could not generate report: " + (err?.message || err); + diagnosticReportError = "Generating the diagnostic bundle didn't work. Your data is unchanged."; + diagnosticReportErrorDetail = String(err?.message ?? err); } finally { diagnosticReportLoading = false; } @@ -395,12 +411,14 @@ await navigator.clipboard.writeText(diagnosticReport); diagnosticReportSavedTo = "Copied to clipboard."; } catch (err) { - diagnosticReportError = "Clipboard copy failed: " + (err?.message || err); + diagnosticReportError = "Copying to clipboard didn't work. Try saving as a file instead."; + diagnosticReportErrorDetail = String(err?.message ?? err); } } async function saveDiagnosticReport() { diagnosticReportError = null; + diagnosticReportErrorDetail = ""; try { const path = await invoke("save_diagnostic_report", { options: { @@ -412,7 +430,35 @@ }); diagnosticReportSavedTo = "Saved to " + path; } catch (err) { - diagnosticReportError = "Save failed: " + (err?.message || err); + diagnosticReportError = "Saving the report file didn't work. Your data is unchanged."; + diagnosticReportErrorDetail = String(err?.message ?? err); + } + } + + async function generateDiagnosticBundle() { + const { save } = await import("@tauri-apps/plugin-dialog"); + const outputPath = await save({ + filters: [{ name: "Zip", extensions: ["zip"] }], + }); + if (!outputPath) return; + diagnosticBundleLoading = true; + try { + const result = await invoke<{ path: string; bytes: number; included: string[]; excluded: string[] }>( + "generate_diagnostic_bundle", + { outputPath }, + ); + const kb = Math.round(result.bytes / 1024); + toasts.success( + "Diagnostic bundle saved", + `${kb} KB saved. You can attach this to a GitHub issue.`, + ); + } catch (err) { + toasts.error( + "Could not save diagnostic bundle", + String(err?.message ?? err), + ); + } finally { + diagnosticBundleLoading = false; } } @@ -429,6 +475,16 @@ let showNewProfile = $state(false); let showNewTemplate = $state(false); + // Arm-confirm state for profile deletion. Mirrors HistoryPage's inline + // confirm pattern (4-second auto-disarm) — first click on Delete arms, + // second click within the window calls through to the storage layer. + // Replaces the prior one-click splice-into-localStorage path that + // silently drifted localStorage and SQLite apart. + const PROFILE_DELETE_CONFIRM_MS = 4000; + let deleteProfileArmedIndex = $state(-1); + let deleteProfileArmTimer: ReturnType | null = null; + let deleteProfileBusy = $state(false); + // Phase 9c: SettingsGroup native
drives disclosure state per // group; no more centralised openSection. Transcription stays open by // default (matches the prior accordion behaviour where it was the @@ -655,6 +711,7 @@ } async function deleteSelectedLlmModel() { + if (!confirm("Delete the selected LLM model file? Lumotia won't be able to use it until you re-download. This cannot be undone.")) return; const modelId = selectedLlmModelId(); try { await invoke("delete_llm_model", { modelId }); @@ -767,7 +824,7 @@ async function testReadAloudVoice() { try { await invoke("tts_speak", { - text: "This is Magnotia reading aloud.", + text: "This is Lumotia reading aloud.", rate: settings.ttsRate, voice: settings.ttsVoice ?? null, }); @@ -816,6 +873,48 @@ page.current = "shutdown"; } + // --- Activation log (Privacy section) --- + interface LumotiaEvent { + id: number; + kind: string; + occurred_at: number; + payload: string | null; + } + + let activationEvents = $state([]); + let loadingEvents = $state(false); + let eventsError = $state(null); + + function formatTimestamp(unixSecs: number): string { + try { + return new Date(unixSecs * 1000).toLocaleString(); + } catch { + return String(unixSecs); + } + } + + async function loadActivationEvents() { + loadingEvents = true; + eventsError = null; + try { + activationEvents = await invoke('list_lumotia_events'); + } catch (err) { + eventsError = String(err); + } finally { + loadingEvents = false; + } + } + + async function confirmAndClear() { + if (!confirm('Clear all activation events? This cannot be undone.')) return; + try { + await invoke('clear_lumotia_events'); + await loadActivationEvents(); + } catch (err) { + eventsError = String(err); + } + } + onMount(async () => { try { await refreshRuntimeCapabilities(); @@ -838,6 +937,9 @@ // reflects reality rather than last-saved intent. syncAutostartFromOs(); + // Activation log — loaded eagerly so Privacy section is ready + loadActivationEvents(); + // Vocabulary is loaded reactively via the $effect that tracks the // active profile id (set once profilesStore.load() resolves in the // root layout). No eager fetch needed here. @@ -870,7 +972,7 @@ downloadTotal = event.payload.total_bytes ?? event.payload.total ?? 0; }); - unlistenLlm = await listen("magnotia:llm-download-progress", (event) => { + unlistenLlm = await listen("lumotia:llm-download-progress", (event) => { llmDownloadProgress = event.payload.percent || 0; llmDownloadingModel = event.payload.modelId || llmDownloadingModel; llmDownloadBytes = event.payload.done ?? 0; @@ -886,6 +988,10 @@ if (unlisten) unlisten(); if (unlistenLlm) unlistenLlm(); if (unlistenParakeet) unlistenParakeet(); + if (deleteProfileArmTimer) { + clearTimeout(deleteProfileArmTimer); + deleteProfileArmTimer = null; + } }); $effect(() => { @@ -1025,15 +1131,66 @@ editingProfile = profiles.length - 1; } - function deleteProfile(index) { - const name = profiles[index].name; - if (page.activeProfile === name) { - page.activeProfile = "None"; + // Two-step delete. First call arms (UI swaps to Confirm/Cancel pills); + // second call inside the window does the work. Routes through the + // SQLite-backed profilesStore so the canonical profile row + its + // profile_terms cascade are removed; legacy localStorage entry is only + // spliced after the storage call succeeds. The storage layer rejects + // deletion when the profile still has transcripts attached — that + // rejection surfaces as a toast via profilesStore.delete(). + function armDeleteProfile(index) { + deleteProfileArmedIndex = index; + if (deleteProfileArmTimer) clearTimeout(deleteProfileArmTimer); + deleteProfileArmTimer = setTimeout(() => { + deleteProfileArmedIndex = -1; + deleteProfileArmTimer = null; + }, PROFILE_DELETE_CONFIRM_MS); + } + + function disarmDeleteProfile() { + deleteProfileArmedIndex = -1; + if (deleteProfileArmTimer) { + clearTimeout(deleteProfileArmTimer); + deleteProfileArmTimer = null; + } + } + + async function confirmDeleteProfile(index) { + if (deleteProfileBusy) return; + if (index < 0 || index >= profiles.length) { + disarmDeleteProfile(); + return; + } + const name = profiles[index].name; + deleteProfileBusy = true; + try { + // Match the legacy localStorage record to its SQL counterpart by + // name. The two systems were grown in parallel: localStorage uses + // name as the natural key, SQL uses a UUID. Until the data model + // is unified we have to bridge by name. + const sqlProfile = profilesStore.profiles.find((p) => p.name === name); + if (sqlProfile && sqlProfile.id !== DEFAULT_PROFILE_ID) { + const before = profilesStore.profiles.length; + await profilesStore.delete(sqlProfile.id); + // profilesStore.delete catches its own errors and emits a toast; + // we detect failure by checking whether the store actually removed + // the row. If it did not, abandon the legacy splice so the two + // sides stay aligned. + if (profilesStore.profiles.length === before) { + return; + } + } + if (page.activeProfile === name) { + page.activeProfile = "None"; + } + removeProfileTaskList(name); + profiles.splice(index, 1); + saveProfiles(); + editingProfile = -1; + } finally { + deleteProfileBusy = false; + disarmDeleteProfile(); } - removeProfileTaskList(name); - profiles.splice(index, 1); - saveProfiles(); - editingProfile = -1; } function profileWordCount(words) { @@ -1101,21 +1258,25 @@
+ the global hotkey above the eight groups. -->

Theme

- + prefs.theme === "light" ? "Light" : "Dark", + (v) => updatePreferences({ theme: v === "Light" ? "light" : "dark" }) + } + />

Font size

@@ -1137,66 +1298,182 @@
- +
-
-

Microphone

-
+ +
+

Microphone

+
+ + +
+ {#if audioDevicesError} +
+

+ + We couldn't list your audio devices. Plug a microphone in or grant Lumotia microphone permission in your OS settings, then try again. +

+
+ Technical details +
{audioDevicesError}
+
+ +
+ {:else if visibleAudioDevices.length === 0} +

No input devices detected. Check that a microphone is connected and PulseAudio/PipeWire is running.

+ {:else} +

+ Auto mode tries the system default first, then any other real input. Speaker-monitor sources (loopback) are skipped. If dictation is silent, pick the device explicitly here. +

+ {/if} +
+ + +
+

Language

+
+ {#if currentModelIsEnglishOnly()} - -
- {#if audioDevicesError} -

{audioDevicesError}

- {:else if visibleAudioDevices.length === 0} -

No input devices detected. Check that a microphone is connected and PulseAudio/PipeWire is running.

{:else} -

- Auto mode tries the system default first, then any other real input. Speaker-monitor sources (loopback) are skipped because they record system audio rather than the microphone. If dictation is silent, pick the device explicitly here. -

+ + {/if} + {#if settings.language === "en"} + {/if}
+

+ {#if currentModelIsEnglishOnly()} + The selected model only supports English in this build. + {:else} + Auto-detect is only meaningful with multilingual models. + {/if} +

+ + +
+

Engine

+ +

+ {settings.engine === "whisper" + ? "Whisper with the currently shipped English-only models in this build" + : "Parakeet CTC 0.6B. English-only, fast when the model is installed"} +

+
+
- + +
+ +
+

Format Mode

+ +

+ {settings.formatMode === "Raw" ? "Exact Whisper output, no formatting" : + settings.formatMode === "Clean" ? "Grouped into paragraphs, punctuation tidied" : + "Structured with lists, headings, and sections"} +

+
+ + +
+

Post-processing

+
+ + +
+
+
+ +

@@ -1339,7 +1616,7 @@ >Add

- +
{#if !showBulkVocab} +
{/if} {#if vocabulary.length === 0} @@ -1407,19 +1700,14 @@ {/if}
- + +
- editingProfile = editingProfile === i ? -1 : i} >{editingProfile === i ? "Close" : "Edit"} - + {#if deleteProfileArmedIndex === i} + + + {:else} + + {/if}
{#if editingProfile === i}
@@ -1477,7 +1778,6 @@
- - +
- - + + {#if settings.engine === "whisper"}
-

Engine

- -

- {settings.engine === "whisper" - ? "Whisper with the currently shipped English-only models in this build" - : "Parakeet CTC 0.6B. English-only, fast when the model is installed"} -

-
+

Whisper Model

+ +

{modelDescriptions[settings.modelSize]}

- -
-

Format Mode

- -

- {settings.formatMode === "Raw" ? "Exact Whisper output, no formatting" : - settings.formatMode === "Clean" ? "Grouped into paragraphs, punctuation tidied" : - "Structured with lists, headings, and sections"} -

-
- - - {#if settings.engine === "whisper"} -
-

Whisper Model

- -

{modelDescriptions[settings.modelSize]}

- -
- {#if isModelLoaded(settings.modelSize)} - - - Model loaded - - {:else if isModelDownloaded(settings.modelSize)} - - - Downloaded - - - {:else if downloadingModel === whisperModelId(settings.modelSize)} - - {downloadProgress}% - {#if downloadTotal > 0} - · {formatBytes(downloadBytes)} / {formatBytes(downloadTotal)} +
+ {#if isModelLoaded(settings.modelSize)} + + + Model loaded + + {:else if isModelDownloaded(settings.modelSize)} + + + Downloaded + + + {:else if downloadingModel === whisperModelId(settings.modelSize)} + + {downloadProgress}% + {#if downloadTotal > 0} + · {formatBytes(downloadBytes)} / {formatBytes(downloadTotal)} + {/if} + {#if downloadProgress > 0 && downloadStartedAt > 0} + {@const remaining = etaSecondsFromPercent(downloadProgress, downloadStartedAt)} + {#if remaining > 1} + · {formatDuration(remaining)} left {/if} - {#if downloadProgress > 0 && downloadStartedAt > 0} - {@const remaining = etaSecondsFromPercent(downloadProgress, downloadStartedAt)} - {#if remaining > 1} - · {formatDuration(remaining)} left - {/if} - {/if} - - {:else} - - {/if} -
+ {/if} +
+ {:else} + + {/if}
- {:else} -
-

Parakeet Model

-

Parakeet CTC 0.6B (int8). ~613MB, near-instant transcription

+
+ {:else} + +
+

Parakeet Model

+

Parakeet CTC 0.6B (int8). ~613MB, near-instant transcription

-
- {#if parakeetOk} - - - Model loaded - - {:else if parakeetDownloaded} - - - Downloaded - - - {:else if parakeetDownloading} - {parakeetProgress}% downloading... - {:else} - - {/if} -
+
+ {#if parakeetOk} + + + Model loaded + + {:else if parakeetDownloaded} + + + Downloaded + + + {:else if parakeetDownloading} + {parakeetProgress}% downloading... + {:else} + + {/if} +
+
+ {/if} + + +
+

AI Model Tier

+
+ {#each LLM_MODELS as model} + + {/each} +
+
+ +
+
+
+

+ {LLM_MODELS.find((model) => model.id === selectedLlmModelId())?.subtitle || "Local model"} +

+

{llmStatus}

+ {#if llmTestHint} +

{llmTestHint}

+ {/if} +
+ +
+ {#if llmDownloadingModel === selectedLlmModelId()} + + {llmDownloadProgress}% + {#if llmDownloadTotal > 0} + · {formatBytes(llmDownloadBytes)} / {formatBytes(llmDownloadTotal)} + {/if} + {#if llmDownloadProgress > 0 && llmDownloadStartedAt > 0} + {@const remaining = etaSecondsFromPercent(llmDownloadProgress, llmDownloadStartedAt)} + {#if remaining > 1} + · {formatDuration(remaining)} left + {/if} + {/if} + + {:else if !llmModelDownloaded(selectedLlmModelId())} + + {:else if !llmModelLoaded(selectedLlmModelId())} + + + + {:else} + + + + {/if} +
+
+ +

+ Recommended for this machine: + + {LLM_MODELS.find((model) => model.id === (settings.llmModelId || "qwen3_5_4b"))?.subtitle || "Qwen3.5 4B"} + + {#if systemInfo} + · {Math.round((systemInfo.ram_mb || 0) / 1024)} GB RAM detected + {/if} +

+
+ +
+ +
+
+ + + + +
+

+ How the Tasks page surfaces progress. Always additive. +

+ + + +
+ + {#if settings.nudgesEnabled} +
+ + +
+ {/if} +
+
+
+ + + +
+ +
+
+ + + +
+ +
+ + All data stays on your machine. No telemetry, no cloud, no accounts. +
+ + +
+

AI use

+

+ The local LLM is used for transcript cleanup and task extraction only — both run fully offline after download. No voice, transcript, or task data is sent to any server. + See privacy-and-ai-use.md for details. +

+
+ + +
+

Activation log

+

+ This log lives on your device only. We never send it anywhere. It records anonymous milestones (first capture, first export, first task extracted) so you can see your own usage shape. +

+ + + + {#if loadingEvents} +

Loading…

+ {:else if eventsError} +

Couldn't load activation log.

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

No activation events recorded yet.

+ {:else} +
+ + + + + + + + + {#each activationEvents as ev} + + + + + {/each} + +
KindWhen
{ev.kind}{formatTimestamp(ev.occurred_at)}
{/if} - -
-

Compute Device

- {#if hasGpuAcceleration()} - -

Only accelerators built into this binary are shown here.

- {:else} -
- This build is CPU-only. GPU controls appear in GPU-enabled builds. -
- {/if} -
- - -
-

Language

-
- {#if currentModelIsEnglishOnly()} - - {:else} - - {/if} - {#if settings.language === "en"} - - {/if} -
-

- {#if currentModelIsEnglishOnly()} - The selected model only supports English in this build. - {:else} - Auto-detect is only meaningful with multilingual models. - {/if} -

-
+
+ + +
+

Data location

+

+ All transcripts, models, and settings are stored in your OS app-data directory. No files are written outside that path. +

+
+
- + +
-
- - -
-
-
- - -
-

- Local LLM for transcript cleanup, smart task extraction, and task breakdown. Runs fully offline after the model is downloaded. -

- +

Feature Tier

@@ -1808,220 +2247,92 @@

{settings.aiTier === "off" - ? "No local LLM calls. Magnotia falls back to the existing rule-based path." + ? "No local LLM calls. Lumotia falls back to the existing rule-based path." : settings.aiTier === "cleanup" ? "Use the local model for transcript cleanup and formatting." : "Use the local model for cleanup, task extraction, and task breakdown."}

+
-

Model Tier

-
- {#each LLM_MODELS as model} - - {/each} -
-
- -
-
-
-

- {LLM_MODELS.find((model) => model.id === selectedLlmModelId())?.subtitle || "Local model"} -

-

{llmStatus}

- {#if llmTestHint} -

{llmTestHint}

- {/if} -
- -
- {#if llmDownloadingModel === selectedLlmModelId()} - - {llmDownloadProgress}% - {#if llmDownloadTotal > 0} - · {formatBytes(llmDownloadBytes)} / {formatBytes(llmDownloadTotal)} - {/if} - {#if llmDownloadProgress > 0 && llmDownloadStartedAt > 0} - {@const remaining = etaSecondsFromPercent(llmDownloadProgress, llmDownloadStartedAt)} - {#if remaining > 1} - · {formatDuration(remaining)} left - {/if} - {/if} - - {:else if !llmModelDownloaded(selectedLlmModelId())} - - {:else if !llmModelLoaded(selectedLlmModelId())} - - - - {:else} - - - - {/if} -
-
- -

- Recommended for this machine: - - {LLM_MODELS.find((model) => model.id === (settings.llmModelId || "qwen3_5_4b"))?.subtitle || "Qwen3.5 4B"} - - {#if systemInfo} - · {Math.round((systemInfo.ram_mb || 0) / 1024)} GB RAM detected +

Cleanup preset

+ +

+ {#if settings.llmPromptPreset === "email"} + Formats output as an email paragraph, tight sentences, no markdown, no auto-added greeting or signoff. + {:else if settings.llmPromptPreset === "notes"} + Formats action items as a bullet list led by imperative verbs; keeps prose informational sentences as prose. + {:else if settings.llmPromptPreset === "code"} + Preserves technical terms, variable names, file paths, and symbols exactly as spoken. No translation of identifiers. + {:else} + No preset, the active profile's prompt governs tone alone. {/if}

-
- +
+

GPU concurrency

+ +

+ {#if settings.aiGpuConcurrency === "sequential"} + On tight-VRAM cards (≤6 GB), loading Whisper + LLM together OOMs. Sequential mode frees the other model before loading; adds a small reload pause between transcribe and cleanup. + {:else} + Both models stay resident in GPU memory. Faster transitions, but needs enough VRAM to hold both at once. + {/if} +

- - -
- -
-

Cleanup preset

- -

- {#if settings.llmPromptPreset === "email"} - Formats output as an email paragraph, tight sentences, no markdown, no auto-added greeting or signoff. - {:else if settings.llmPromptPreset === "notes"} - Formats action items as a bullet list led by imperative verbs; keeps prose informational sentences as prose. - {:else if settings.llmPromptPreset === "code"} - Preserves technical terms, variable names, file paths, and symbols exactly as spoken. No translation of identifiers. - {:else} - No preset, the active profile's prompt governs tone alone. - {/if} -

+ +
+

Compute Device

+ {#if hasGpuAcceleration()} + +

Only accelerators built into this binary are shown here.

+ {:else} +
+ This build is CPU-only. GPU controls appear in GPU-enabled builds.
- - -
-

GPU concurrency

- -

- {#if settings.aiGpuConcurrency === "sequential"} - On tight-VRAM cards (≤6 GB), loading Whisper + LLM together OOMs. Sequential mode frees the other model before loading; adds a small reload pause between transcribe and cleanup. - {:else} - Both models stay resident in GPU memory. Faster transitions, but needs enough VRAM to hold both at once. - {/if} -

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

- How the Tasks page surfaces progress. Always additive. -

+ +
+

+ Uses your operating system's built-in voices. No audio leaves the machine. +

- - -
-
- - -
-

- Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Magnotia. -

- - - - {#if settings.nudgesEnabled} -
- - -
- {/if} -
-
- - - - - -
-

- Uses your operating system's built-in voices. No audio leaves the machine. -

- -
- - - {#if ttsVoicesError} -

{ttsVoicesError}

- {:else if ttsVoicesLoaded && ttsVoices.length === 0} -

- No additional voices reported by the system synth. Install extra voices through your OS accessibility settings. -

- {/if} -
- -
- - -
- Slower - Normal - Faster -
-
- - -
-
- - -
-
- - - - - {#if settings.meetingAutoCapture} -
-

Apps to watch (comma-separated)

- { - const raw = event.currentTarget.value; - settings.meetingAutoCaptureApps = raw - .split(",") - .map((entry) => entry.trim().toLowerCase()) - .filter((entry) => entry.length > 0); - }} - /> -
- {/if} - - {#if settings.soundCues} -
- - - {Math.round(settings.soundCueVolume * 100)}% - -
- {/if} - - - {#if settings.saveAudio} -
-

Output Folder

-
-
-

- {outputFolderPreview} -

-
+
+ + + {#if ttsVoicesError} +
+

+ + We couldn't load the text-to-speech voices. The rest of Settings still works. +

+
+ Technical details +
{ttsVoicesError}
+
- {#if settings.outputFolder} - - {/if} + class="mt-1.5 text-[11px] text-accent underline underline-offset-2 hover:text-accent/80" + onclick={refreshTtsVoices} + type="button" + >Try again
-
- {/if} -
-
- - + {:else if ttsVoicesLoaded && ttsVoices.length === 0} +

+ No additional voices reported by the system synth. Install extra voices through your OS accessibility settings. +

+ {/if} +
- - +
+ + +
+ Slower + Normal + Faster +
+
+ + +
+
+ + +
+
+ + + + + {#if settings.meetingAutoCapture} +
+

Apps to watch (comma-separated)

+ { + const raw = event.currentTarget.value; + settings.meetingAutoCaptureApps = raw + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0); + }} + /> +
+ {/if} + + {#if settings.soundCues} +
+ + + {Math.round(settings.soundCueVolume * 100)}% + +
+ {/if} + + + {#if settings.saveAudio} +
+

Output Folder

+
+
+

+ {outputFolderPreview} +

+
+ + {#if settings.outputFolder} + + {/if} +
+
+ {/if} +
+
+
+
+ +

Theme

- + prefs.theme === "light" ? "Light" : prefs.theme === "system" ? "System" : "Dark", + (v) => updatePreferences({ theme: v === "Light" ? "light" : v === "System" ? "system" : "dark" }) + } + />
@@ -2398,23 +2657,13 @@
- -
- -
-
- +
-
{engineStatus} @@ -2435,9 +2684,8 @@ {/each}
-

Magnotia v1.0 · Powered by whisper.cpp · Built by CORBEL Ltd

+

Lumotia v1.0 · Powered by whisper.cpp · Built by CORBEL Ltd

-

Diagnostics

@@ -2464,7 +2712,23 @@ {/if}

{#if diagnosticReportError} -

{diagnosticReportError}

+
+

+ + {diagnosticReportError} +

+ {#if diagnosticReportErrorDetail} +
+ Technical details +
{diagnosticReportErrorDetail}
+
+ {/if} + +
{/if} {#if diagnosticReportSavedTo}

{diagnosticReportSavedTo}

@@ -2479,6 +2743,62 @@
+ + + + +
+
+

+ Need to walk through onboarding again? Replay the first-run tutorial and + the gate will skip past your existing data. +

+ +
+ +
+ +

+ Logs + system info + redacted preferences. Never includes audio or transcript content. +

+
+ +
+

+ Known limitations for this release are documented in + KNOWN-ISSUES.md. + Bugs and feature requests can be filed at + github.com/jakeadriansames/lumotia/issues. +

+
+
+
diff --git a/src/lib/pages/ShutdownRitualPage.svelte b/src/lib/pages/ShutdownRitualPage.svelte index 78c890d..e367401 100644 --- a/src/lib/pages/ShutdownRitualPage.svelte +++ b/src/lib/pages/ShutdownRitualPage.svelte @@ -19,6 +19,8 @@ import { Moon, ArrowLeft } from 'lucide-svelte'; import { page } from '$lib/stores/page.svelte.js'; import { hasTauriRuntime } from '$lib/utils/runtime.js'; + import LumotiaButton from '$lib/ui/LumotiaButton.svelte'; + import LumotiaIconButton from '$lib/ui/LumotiaIconButton.svelte'; interface TaskRow { id: string; @@ -72,14 +74,7 @@
- +
@@ -156,13 +151,7 @@
- + Close
{/if}
diff --git a/src/lib/pages/TasksPage.svelte b/src/lib/pages/TasksPage.svelte index 4f8daa9..b8de465 100644 --- a/src/lib/pages/TasksPage.svelte +++ b/src/lib/pages/TasksPage.svelte @@ -10,11 +10,11 @@ } from "$lib/stores/page.svelte.js"; import { recentCompletions, todayCount } from "$lib/stores/completionStats.svelte"; import WipTaskList from '$lib/components/WipTaskList.svelte'; - import EmptyState from '$lib/components/EmptyState.svelte'; + import LumotiaEmptyState from '$lib/ui/LumotiaEmptyState.svelte'; + import LumotiaButton from '$lib/ui/LumotiaButton.svelte'; import CompletionSparkline from "$lib/components/CompletionSparkline.svelte"; import EnergyChip from '$lib/components/EnergyChip.svelte'; import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Zap } from 'lucide-svelte'; - import Card from "$lib/components/Card.svelte"; import { formatTimestamp } from "$lib/utils/time.js"; import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js"; @@ -261,6 +261,7 @@ } function handleDeleteList(id: string) { + if (!confirm("Delete this task list? All tasks in it will be removed. This cannot be undone.")) return; if (activeListId === id) activeListId = "all"; deleteTaskList(id); contextMenuListId = null; @@ -359,15 +360,15 @@
- +
@@ -575,7 +576,7 @@
{#if filteredTasks.length === 0} - deleteTask(task.id)} + onclick={() => { if (confirm("Delete this task? This cannot be undone.")) deleteTask(task.id); }} >