27 Commits

Author SHA1 Message Date
2491c7a7dd agent: lumotia-rebrand — fix Codex cross-model review blockers
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Codex independent review found 11 blockers post-cascade. All addressed.

CRITICAL (data-loss / crash):

10. crates/core/src/paths.rs — migrate_legacy_data_dir_inner used
    fs::rename which fails with EXDEV when source + target are on
    different filesystems (encrypted-home, bind mounts, separate
    $XDG_DATA_HOME partition). Combined with the Phase 5 QC fix that
    made migration errors fatal, this would crash on first launch
    for any user whose data dir spans filesystems. Added
    rename_or_copy_tree() that falls back to copy_dir_recursive +
    remove_dir_all on CrossesDevices / errno 18 (EXDEV). Symlinks
    preserved verbatim. Same fallback applied to magnotia.db ->
    lumotia.db inside the dir.

11. Added 4 unit tests: copy_dir_recursive preserves nested
    structure, rename_or_copy_tree same-filesystem happy path,
    is_cross_device classifies CrossesDevices kind + raw errno 18.

Doc residuals (blockers 1-9):

1. crates/cloud-providers/Cargo.toml — "Wyrdnote pending rebrand"
   description.
2. crates/cloud-providers/src/provider.rs — module docs + test
   fixture Wyrdnote refs.
3. crates/transcription/src/orchestrator.rs — module docs + test
   fixture Wyrdnote refs.
4. docs/roadmap/2026-05-10-pkm-phase-tooling-shortlist.md — phase
   name + outputs/wyrdnote path refs.
5. docs/architecture-map/04-llm-formatting-mcp/llm-tests.md —
   MAGNOTIA_LLM_TEST_MODEL env var.
6. .../cloud-providers-stubs.md — MAGNOTIA_API_KEY_*.
7. docs/architecture-map/03-audio-transcription/tests-and-fixtures.md
   — MAGNOTIA_WHISPER_*.
8. docs/gpu-tuning/plan.md — MAGNOTIA_BENCH_RUN.
9. docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-
   design.md + corresponding plan — MAGNOTIA_POWER_STATE_OVERRIDE,
   MAGNOTIA_INFERENCE_THREADS.

cargo test --workspace: 343 pass / 0 fail (up from 339; +4 EXDEV
fallback tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:39:21 +01:00
f093d18a5e agent: lumotia-rebrand — final residuals (HANDOVER + arch-map readme + ui-kit React fn)
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 15.1 final-grep residuals:
- HANDOVER.md "Rebrand note" + Phase 10b row updated to reflect that
  the cascade completed 2026/05/13 (15 phases, both repos, QC-gated).
- HANDOVER.md two surviving sed artefacts: "Lumotia -> Lumotia" line
  restored to "Magnotia -> Lumotia" historical context;
  MAGNOTIA_LLM_TEST_MODEL test gate -> LUMOTIA_LLM_TEST_MODEL.
- src/design-system/ui_kits/index.html: MagnotiaApp React function ->
  LumotiaApp (sed boundary missed the no-separator boundary).
- docs/architecture-map/README.md: MAGNOTIA_LLM_TEST_MODEL doc note.

Preserved (audit trail):
- docs/handovers/ — historical handover docs.
- docs/superpowers/plans/2026-05-12-engine-slop-residuals.md and
  -area-a-storage-errors-survey.md — describe the slop-pass work
  using the names current at the time.
- build/index.html, package-lock.json — regenerate on next build/install.

cargo test --workspace: 339 pass / 0 fail. npm run check: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:19:34 +01:00
26c7307607 agent: lumotia-rebrand — docs, scripts, root config, residuals
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 9 of the rebrand cascade. Sweep covers everything the Phase 8
frontend pass deliberately skipped: docs/, root markdown, scripts,
Cargo.toml descriptions, code comments that survived earlier
word-boundary sed, plus a handful of identifiers caught on the final
verify pass.

transcription-app changes:
- README.md, HANDOVER.md, KNOWN-ISSUES.md, run.sh — magnotia/Magnotia
  -> lumotia/Lumotia.
- docs/ — sweep across all subdirs except docs/handovers/ (preserved
  as immutable audit trail). Includes architecture-map references
  to magnotia_core::*, magnotia_storage::*, etc. now pointing at
  lumotia_*; dev-setup.md tracing output examples (lumotia_startup
  target); brief/ + superpowers/ + issues/ + whisper-ecosystem/ +
  audit/.
- Cargo.toml descriptions on 9 crates (core, audio, cloud-providers,
  hotkey, llm, mcp, plus referenced others).
- crates/core/src/{error,hardware,recommendation,paths}.rs +
  crates/audio/src/wav.rs + crates/llm/src/model_manager.rs +
  crates/cloud-providers/src/keystore.rs + crates/mcp/src/lib.rs —
  doc comments and a model-manager user-agent string.
- Caught on final pass: BroadcastChannel("magnotia_task_sync") -> ...
  ("lumotia_task_sync"); magnotia_locale i18n localStorage key
  renamed + migration shim added; CSS keyframe names
  magnotiaPulse / magnotiaBar / magnotiaFade renamed in the design-
  system kit; magnotia_viewer_item / magnotia_viewer_mode handoff
  keys renamed in HistoryPage + viewer/+page.svelte; src/assets/
  wordmark.svg text.
- src-tauri/src/lib.rs comment cleanup ("magnotia era" was sed'd
  to "lumotia era" earlier — restored).

Preserved (intentional):
- crates/core/src/paths.rs — keeps "magnotia" / "Magnotia" / ".magnotia"
  legacy detection strings in legacy_and_target_paths() so the
  migration shim can still find user data from the magnotia era.
- src/lib/stores/{page,focusTimer}.svelte.ts + src/lib/i18n/index.ts
  — migration call sites reference the legacy magnotia keys
  deliberately.
- docs/handovers/ — historical audit trail.

cargo build --workspace passes. npm run check: 0 errors / 0 warnings
(3958 files). cargo test --workspace: 339 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:38:03 +01:00
681a9b26dc agent: lumotia-rebrand — frontend strings (svelte + i18n + design-system)
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 8 of the rebrand cascade. Every rendered string is now Lumotia;
no Magnotia surface visible in the UI.

Sweep replaced \bmagnotia\b -> lumotia and \bMagnotia\b -> Lumotia
across all .svelte / .ts / .js / .css / .html / .json (excluding
package-lock.json which regenerates, target/, build/, node_modules/).

Surfaces touched:
- src/app.css — design-token comment header and .magnotia-rh-* CSS
  resize-handle class selectors (also the consuming elements in
  components/ResizeHandles.svelte and src/routes/*/+layout.svelte).
- src/lib/i18n/locales/{en,de,es}.json — brand name in translations.
- src/lib/i18n/index.ts — header comment.
- src/lib/Sidebar.svelte and most pages under src/lib/pages/ +
  src/lib/components/ — title bars, document titles, default
  filenames (lumotia-YYYY-MM-DD.* etc), toast strings, error
  messages, dialog headers.
- src/routes/+layout.svelte, +page.svelte, viewer/, float/, preview/.
- src/app.html page <title>.
- src/lib/utils/settingsMigrations.ts — fallback toast copy.
- src/design-system/{colors_and_type.css,SKILL.md,README.md,
  ui_kits/{Sidebar.jsx,index.html}} — design-tokens, doc strings,
  preview wordmark in the kit.
- package.json — name + description.

NOT touched (deferred / immutable):
- package-lock.json — regenerates on next npm install.
- The two migration-call sites in stores reference the legacy magnotia
  keys deliberately; restored after the sweep clobbered them.
- docs/, README.md, HANDOVER.md — Phase 9 scope.

npm run check: 0 errors / 0 warnings (3958 files).
cargo test --workspace: 339 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:29:37 +01:00
16081095e0 agent: lumotia-rebrand — localStorage keys + event channels migration
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 7 of the rebrand cascade. Persisted UI state + inter-window event
channels migrated from magnotia to lumotia naming, with one-shot
localStorage key migration so dogfooded UI state survives the rename.

src/lib/utils/localStorageMigration.ts (new):
- migrateLocalStorageKey(old, new): idempotent + crash-safe shim.
  - If new key exists, removes old (lumotia value is authoritative).
  - If only old exists, copies value to new key, removes old.
  - If neither, no-op.
- migrateLocalStorageKeys(pairs): batch wrapper.

src/lib/stores/page.svelte.ts:
- 4 key constants renamed to lumotia_settings / lumotia_profiles /
  lumotia_task_lists / lumotia_templates.
- BroadcastChannel name renamed to lumotia_task_lists.
- migrateLocalStorageKeys() called at module load before any read.

src/lib/stores/focusTimer.svelte.ts:
- STORAGE_KEY renamed to lumotia.focusTimer.v1.
- migrateLocalStorageKey() called at module load.

Event channels (magnotia: -> lumotia:) renamed across frontend + Rust:
- magnotia:toggle-recording (src/routes/+layout.svelte)
- magnotia:hotkey-pressed / -released (src-tauri/src/commands/hotkey.rs +
  consumers)
- magnotia:open-wind-down (src-tauri/src/tray.rs + consumer)
- magnotia:llm-download-progress (src-tauri/src/commands/llm.rs)
- magnotia:preferences-changed (src/lib/stores/preferences.svelte.ts +
  consumers)
- magnotia:start-timer (nudgeBus + dispatch sites)
- magnotia:focus-timer-{complete,cancelled} (focusTimer + nudgeBus)
- magnotia:microstep-generated (nudgeBus + dispatch sites)
- magnotia:step-completed (nudgeBus + dispatch sites)
- magnotia:task-{completed,uncompleted,deleted} (page.svelte.ts +
  nudgeBus + consumers)

Storage-event filters in src/routes/{float,viewer,preview}/+layout@.svelte
updated to filter on lumotia_settings.

User-facing toast strings still say "Magnotia" — deferred to Phase 8
(frontend strings).

npm run check: 0 errors / 0 warnings (3958 files).
cargo test --workspace: 339 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:10:50 +01:00
14313cfa84 agent: lumotia-rebrand — tauri productName, identifier, window title
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 6 of the rebrand cascade per locked decision D2.

src-tauri/tauri.conf.json:
- productName: "Magnotia" -> "Lumotia"
- identifier:  "uk.co.corbel.magnotia" -> "consulting.corbel.lumotia"
- window title: "Magnotia" -> "Lumotia"

D2 picks the reverse-DNS of corbel.consulting (the actual domain CORBEL
trades under) over the prior uk.co.corbel.* convention. This is the
identity the OS uses for installed-app keying, so the first launch under
the new identifier will look fresh to Tauri's plugin state (window-state,
autostart). Per D1 the user-data dir is migrated by lumotia_core::paths
on first boot, so transcripts and settings survive.

cargo build --workspace passes. cargo test --workspace: 339 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:47:28 +01:00
9336286e3c agent: lumotia-rebrand — fix QC blockers for phase 5
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 5 QC found two blockers + four advisories. All addressed:

B1 (FATAL) — Migration error now aborts startup instead of silently
  continuing past it. Without this fix a transient EACCES / EXDEV / ENOSPC
  would log a warning, init_db would create a fresh empty lumotia dir,
  and the user would appear to lose their transcripts.

B2 (FATAL) — Linux dot-home vs XDG mismatch. The old probe returned
  ~/.magnotia as legacy but the caller passed app_data_dir() as the new
  path — which could be $XDG_DATA_HOME/lumotia. fs::rename across
  filesystems would EXDEV-fail; even when it succeeded the user's
  storage convention silently changed.

  Refactored: legacy_and_target_paths() returns the (legacy, target)
  pair together. Dot-home legacy lands in ~/.lumotia; XDG-set legacy
  lands in $XDG_DATA_HOME/lumotia; XDG-default legacy lands in
  ~/.local/share/lumotia. macOS / Windows / non-tier-1 unchanged.

  migrate_legacy_data_dir() now takes no argument; src-tauri caller
  updated.

A1 — Removed dead new_db.exists() check inside rename_db_file_if_present
  (unreachable: rename_db is called only AFTER the legacy dir was just
  renamed to the new path, so a stray lumotia.db there is impossible).

A2 — Added 4 unit tests for migrate_legacy_setting_keys: lone-magnotia,
  both-present (orphan delete), no-magnotia, idempotent.

A3 — migrate_legacy_setting_keys now returns (renamed, orphans_deleted).
  When both keys exist, the legacy magnotia row is DELETED in the same
  transaction (lumotia row is authoritative).

A4 — Added dot-home convention regression test in paths::tests.

cargo build --workspace passes. cargo test --workspace: 339 pass, 0 fail
(up from 334 in the original Phase 5 commit; +5 new tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:43:45 +01:00
86f83b7a45 agent: lumotia-rebrand — data dir migration shim + paths.rs rename
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 5 of the rebrand cascade per locked decision D1 (migrate in place).

crates/core/src/paths.rs:
- Hardcoded subdir strings renamed: magnotia/Magnotia -> lumotia/Lumotia
  across all four OS branches (Linux XDG + dot-legacy, macOS Application
  Support, Windows LOCALAPPDATA, fallback dot-dir).
- Database filename: magnotia.db -> lumotia.db.
- Test path fixtures renamed: /tmp/magnotia-test -> /tmp/lumotia-test.
- New MigrationStatus enum (Migrated / TargetAlreadyExists / NoLegacyFound).
- New migrate_legacy_data_dir() that probes the platform-correct legacy
  magnotia path, renames it to the lumotia equivalent via fs::rename, and
  also renames magnotia.db -> lumotia.db inside if found. Idempotent: safe
  to call on every boot. Refuses to overwrite an existing lumotia dir to
  protect user data.
- Four new unit tests covering all branches via the test-friendly
  migrate_legacy_data_dir_inner that takes an explicit legacy path.

crates/storage/src/database.rs:
- New migrate_legacy_setting_keys(pool) that renames any settings rows
  with key matching magnotia_* to lumotia_*. Single SQL UPDATE with
  NOT EXISTS guard so it leaves rows alone if a lumotia_ row already
  exists. Re-exported from lib.rs.

src-tauri/src/lib.rs:
- Calls migrate_legacy_data_dir(&app_data_dir()) at the start of setup()
  BEFORE database_path() resolves the now-renamed dir. Logs migration
  outcome to lumotia_startup tracing target.
- Calls migrate_legacy_setting_keys(&db) immediately after init_db
  returns. Logs only when rows are actually renamed.

src-tauri/src/{lib,commands/{diagnostics,rituals,transcripts}}.rs +
crates/storage/src/database.rs:
- All in-code references to magnotia_preferences, magnotia_history (in
  comments), and magnotia_morning_triage_last_shown renamed to lumotia_*.

cargo build --workspace passes. cargo test --workspace: 334 pass, 0 fail
(up from 330; +4 paths::tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:21:45 +01:00
e2a5feb718 agent: lumotia-rebrand — tracing filter targets
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 4 of the rebrand cascade. Updates the default RUST_LOG filter
in src-tauri/src/lib.rs and all 'target: "magnotia_startup"' string
literals across src-tauri/src/lib.rs and src-tauri/src/commands/models.rs.

Default filter (before):
  warn,magnotia=info,lumotia_lib=info,lumotia_audio=info,
  magnotia_startup=info,lumotia_hotkey=info,lumotia_ai_formatting=info

Default filter (after):
  warn,lumotia=info,lumotia_lib=info,lumotia_core=info,
  lumotia_audio=info,lumotia_hotkey=info,lumotia_ai_formatting=info,
  lumotia_llm=info,lumotia_storage=info,lumotia_transcription=info,
  lumotia_startup=info

magnotia_startup was a logical tracing target (not a crate); renamed
to lumotia_startup for consistency. magnotia_lib was already renamed
to lumotia_lib in Phase 2. Added per-crate filter entries for parity
across the workspace.

cargo build --workspace passes. cargo test --workspace: 330 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:00:33 +01:00
42f4d07e48 agent: lumotia-rebrand — drop MagnotiaError prefix, now lumotia_core::Error
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 3 of the rebrand cascade per locked decision D4. MagnotiaError ->
Error in crates/core/src/error.rs (the crate name already qualifies it).
92 usages across 14 .rs files renamed via word-boundary sed.

One collision required disambiguation: lumotia_storage already had its
own local Error type (introduced by the slop-pass Area A residuals work).
crates/storage/src/error.rs aliases the imported core error as CoreError
on import; the From<Error> for CoreError boundary impl and the
CoreError::Storage construction site use the alias.

cargo build --workspace passes. cargo test --workspace: 330 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:58:05 +01:00
ce6dc1e728 agent: lumotia-rebrand — fix QC blockers for phase 2
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 2 QC found three explicit blockers + a broader sweep needed:

Explicit (QC-named):
- crates/mcp/src/lib.rs:15 — SERVER_NAME public MCP wire identity
- crates/transcription/build.rs:59 — panic message prefix
- crates/llm/tests/content_tags_smoke.rs:7 — docstring -p flag

Swept (string literals + dev env vars + doc comments + test fixtures):
- crates/mcp/src/main.rs — eprintln log prefixes
- src-tauri/src/commands/diagnostics.rs — diagnostic filename + MAGNOTIA_VERSION
- src-tauri/src/commands/audio.rs — recording filename pattern lumotia-<secs>-...wav
- src-tauri/src/commands/fs.rs — test placeholder path
- crates/transcription/src/model_manager.rs — .lumotia-verified marker
- crates/storage/src/database.rs — lumotia-storage-ro-<pid> temp dirs + doc comments
- crates/cloud-providers/src/keystore.rs — LUMOTIA_API_KEY_<PROVIDER> env var
- crates/audio/src/{wav,decode}.rs — lumotia_test_* / lumotia_decode_* test fixtures
- crates/core/src/tuning.rs — LUMOTIA_INFERENCE_THREADS env var
- All MAGNOTIA_LLM_TEST_MODEL / MAGNOTIA_WHISPER_TEST_* env vars
- Doc comments referencing crate names

Excluded (intentional Phase 4/5 scope):
- magnotia_preferences, magnotia_history, magnotia_morning_triage_last_shown
  DB setting keys (Phase 5 paths.rs migration)
- magnotia_startup tracing target (Phase 4)
- crates/core/src/paths.rs (Phase 5 wholesale rewrite + migration shim)

cargo build --workspace passes. cargo test --workspace: 330 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:52:47 +01:00
089349d966 agent: lumotia-rebrand — rust workspace crates magnotia-* -> lumotia-*
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 2 of the rebrand cascade. Renames all 9 workspace crates from
magnotia-* to lumotia-* plus the src-tauri binary crate name:

- magnotia-ai-formatting   -> lumotia-ai-formatting
- magnotia-audio           -> lumotia-audio
- magnotia-cloud-providers -> lumotia-cloud-providers
- magnotia-core            -> lumotia-core
- magnotia-hotkey          -> lumotia-hotkey
- magnotia-llm             -> lumotia-llm
- magnotia-mcp             -> lumotia-mcp
- magnotia-storage         -> lumotia-storage
- magnotia-transcription   -> lumotia-transcription
- magnotia                 -> lumotia (src-tauri binary)
- magnotia_lib             -> lumotia_lib (src-tauri lib target)

Crate directories (crates/audio/ etc.) stay as-is; only the Cargo.toml
[package] name field changes plus all consumer module imports
(magnotia_core -> lumotia_core, etc.).

Remaining magnotia_* references at this point are intentional and
scoped to later phases: tracing targets (Phase 4), DB setting keys
magnotia_preferences/magnotia_history (Phase 5).

cargo build --workspace passes. cargo test --workspace: 330 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:48:09 +01:00
bc2db91520 agent: lumotia-rebrand — fix QC blockers for phase 0
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Phase 0 QC found two real blockers in the baseline commits:

1. crates/llm/src/lib.rs:215 — early-break guard was inverted. The
   guard fired when grammar.is_some() (where grammar already enforces
   shape, making the check redundant) and was disabled for
   grammar.is_none() (the content-tags path that actually needs it).
   Flipped to grammar.is_none() so the JSON-envelope short-circuit
   fires for free-form JSON generation as the commit message
   implied.

2. src/lib/pages/FilesPage.svelte — handleExport() success path had
   no toast, asymmetric with DictationPage which does. Added the
   toasts import and a toasts.success() call after the native save
   so both export surfaces give consistent feedback.

cargo test -p magnotia-llm --lib: 21 pass. npm run check: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:42:22 +01:00
2ca01e7c9d feat(export): prefer native save dialog over browser blob fallback
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
DictationPage + FilesPage handleExport() now use Tauri save() +
write_text_file_cmd when in the desktop runtime; browser blob path
remains as fallback when hasTauriRuntime() is false. saveMarkdown.ts
surfaces dialog errors via toasts. Adds txt to the extension map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:26:19 +01:00
1d71e8e361 refactor(llm): remove GBNF grammar, switch to JSON-envelope extractor
extract_content_tags now generates with grammar=None and parses the
response via a manual brace-counting JSON envelope extractor that
handles Qwen <think>...</think> prefixes and trailing stop tokens.
Five new unit tests. Bumps llama-cpp-2 to 0.1.146. Explicit
features=[] on tauri dependency (no-op).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:25:55 +01:00
b0d4ac8de1 docs(phase10a): re-validation pass entry for 2026/05/12 stack
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Smoke checks (native window, Settings, Record, no error toasts) PASS on
the post-residuals-B/tracing/Area-A stack. Hermes session crashed
before the longer-form dogfood pass (transcribe + LLM cleanup + tagging)
could run; the LLM tagging crash from 2026/05/10 remains the
outstanding release-blocker. Tracing pipeline confirmed working under
the new default filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:45:32 +01:00
c55b0aaae0 docs: update residuals status after tracing and storage passes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:27:15 +01:00
a36ae7e068 agent: remove legacy string storage error variant
Commit 52565ea migrated storage to magnotia_storage::Error and flattened
typed storage failures into MagnotiaError::Storage { kind, operation,
detail }. No production code constructs the old
MagnotiaError::StorageError String variant anymore.

Remove the legacy variant so new storage failures cannot regress back to
stringly-typed errors.

Also updates living architecture-map docs that referenced the old
variant (core-error.md variant table, storage-overview.md
SQLITE_BUSY note, storage-crud-profiles.md duplicate-name + default-
profile-rename notes, storage-crud-transcripts.md pre-flight FK check
note) and one stale code comment in crates/storage/src/database.rs's
duplicate-name test. Survey doc + old residuals plan + phase8 historical
plan deliberately left alone — they're audit trail of how the migration
was decided, not living docs.

Pre-existing doc rot flagged but not fixed (Other(String) and
Io(std::io::Error) rows in core-error.md are about variants that
already don't match the actual enum shape — separate doc cleanup pass).

Verification:
- cargo fmt --all -- --check
- cargo check -p magnotia-core
- cargo check -p magnotia-storage
- cargo check --workspace --all-targets
- cargo test -p magnotia-storage — 60 passed, 0 failed
- cargo test --workspace --lib — all green
- rg 'StorageError\(' crates/ src-tauri/src/ — zero hits
- rg 'StorageError' crates/ src-tauri/src/ docs/architecture-map/ — zero
- rg 'Other\(String\)' crates/ src-tauri/src/ — zero

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:08:56 +01:00
52565ea8b8 agent: area A commit 1 — typed magnotia_storage::Error + boundary
Commit 1 of 2 for engine-slop residuals Area A.

Adds a typed `magnotia_storage::Error` enum and rewires every error
construction site in the storage crate to use it. The crate boundary
into `magnotia_core::error::MagnotiaError` is handled by a
`From<storage::Error> for MagnotiaError` impl that lives inside the
storage crate (not in core) to avoid a `core -> storage` dependency
cycle. The old `MagnotiaError::StorageError(String)` variant is kept
in this commit as an unused placeholder; commit 2 deletes it.

New types in crates/storage/src/error.rs:
- pub enum Error { DatabaseOpen, Migration, Query, NotFound,
  InvalidReference, Filesystem } with #[derive(thiserror::Error)]
- pub enum OpenOp { Connect, ReadOnlyConnect, ForeignKeysPragma }
- pub enum MigrationStep { SchemaVersionTableCreate, SchemaVersionQuery,
  TxBegin, Apply, RecordVersion, Commit }
- pub enum Entity { Transcript, Task, Profile, ImplementationRule,
  Feedback } — seeded only with entities that have a real
  NotFound/InvalidReference case in the codebase
- pub type Result<T> = std::result::Result<T, Error>
- impl Error { pub fn kind() -> StorageKind, pub fn
  operation_label() -> Cow<'static, str> }
- impl From<Error> for MagnotiaError

New types in crates/core/src/error.rs:
- MagnotiaError::Storage { kind: StorageKind, operation: String,
  detail: String } with #[error("{detail}")] so the boundary doesn't
  double-prefix Display output
- pub enum StorageKind { DatabaseOpen, Migration, Query, NotFound,
  InvalidReference, Filesystem } #[derive(Serialize)]
  #[serde(rename_all = "snake_case")] for future Area E

Storage crate dependency added: thiserror = "1"

Migration scope:
- migrations.rs: 6 sites → Error::Migration with structured step + version
- database.rs: 72 sites broken down as:
  * 3  → Error::DatabaseOpen (init / readonly / pragma)
  * 1  → Error::Filesystem (init's create_dir_all with path context)
  * ~58 → Error::Query with operation labels matching the prior message
        stem in snake_case; transaction sub-steps use dotted labels
        ("complete_subtask_and_check_parent.commit_transaction" etc.)
        so transaction internals are not collapsed
  * 5  → Error::NotFound (transcript / task×2 / implementation_rule×2)
  * 5  → Error::InvalidReference (insert_transcript unknown profile;
        Default profile rename/delete invariants ×3; feedback rating
        value validation)

Survey ambiguities resolved per the locked answers:
- Q1 (schema_version_query): Migration step, not Query
- Q2 (transaction sub-steps): preserved granularity with dotted operation
  labels; no collapsing
- Q3 (Entity cardinality): seeded with the 3 from the survey + 2 more
  discovered during migration (ImplementationRule, Feedback), per
  "add when an actual case needs it"
- Q4 (operation label type): Cow<'static, str>
- Q5 (Serialize): storage::Error is not serializable; flattens only at
  the MagnotiaError boundary
- Q6 (re-exports): pub use error::{Entity, Error, MigrationStep, OpenOp,
  Result} in storage::lib.rs; StorageKind belongs to magnotia_core

Two scope-discovered additions beyond the original 5 variants:
- Error::Filesystem { path, source } variant + matching StorageKind —
  required because init() now returns storage::Result<SqlitePool> and
  the create_dir_all call needs a typed path; doing this via
  From<io::Error> for storage::Error would have lost the path so it's
  explicit
- Entity::ImplementationRule and Entity::Feedback — two NotFound /
  InvalidReference sites the original survey missed in the rule-CRUD
  and feedback-validation areas

Verification:
- cargo fmt --all
- cargo check --workspace --all-targets — clean
- cargo test -p magnotia-storage — 60 passed, 0 failed
- cargo test --workspace — all green, ~330 tests
- rg 'StorageError\(' crates/ src-tauri/src/ — only hit is the variant
  declaration in core that commit 2 will delete
- rg 'Other\(String' crates/storage/src/ — zero
- rg 'format!\("[A-Z].* failed' crates/storage/src/ — zero

Commit 2 will delete MagnotiaError::StorageError(String) once this is
in. Pre-existing working-tree churn in crates/llm/, src/lib/pages/,
src/lib/utils/saveMarkdown.ts and the untracked phase10a dogfood notes
deliberately left unstaged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:57:17 +01:00
fdab77776c docs(area-a): storage error inventory + proposed typed taxonomy
Pre-migration survey for engine-slop residuals Area A. Catalogues every
MagnotiaError::StorageError construction site in the storage crate
(78 total across database.rs and migrations.rs), groups by failure
mode, proposes a typed magnotia_storage::Error enum with a serde-
friendly MagnotiaError::Storage { kind, operation, detail } variant on
top, and flags 6 ambiguities + 3 migration risk classes.

Key findings the residuals plan did not capture:
- StorageError is a String variant on MagnotiaError, not a separate
  enum — there is nothing called StorageError in the storage crate.
- Actual site count is 78, not the ~25 the residuals plan estimated.
- Tauri commands stringify every error before crossing into the
  frontend (Result<T, String>), so MagnotiaError's Serialize impl is
  presently unused at the FE/BE boundary. Area E owns that fix.
- Zero Other(String) sites in storage — already cleaned in db654de.

No code changes. Migration awaits decisions on the 6 ambiguous cases
in §"Ambiguous cases / decisions needed".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:39:27 +01:00
48c483894b agent: finish tracing migration for hotkey logs
Follow-up to 184214b. The eprintln→tracing sweep surfaced 6 existing
log::* calls in the hotkey crate that were silent at runtime — the
workspace builds tracing-subscriber without the tracing-log feature, so
log:: events never reached the tracing pipeline.

Migrating direct rather than adding a LogTracer bridge: the repo is
already standardising on tracing, the bridge adds a second logger init
path with "already initialised" edge cases, and the bridge would
indiscriminately route all log records (including future third-party
chatter), not just these known sites.

Migrated (6 sites, 2 files):
- crates/hotkey/src/linux.rs (5) — read /dev/input error, device open
  debug, device-attached info, device-listener-ended warn, event-channel-
  closed warn
- crates/hotkey/src/stub.rs (1) — non-Linux no-op info

Also removed the now-unused log = "0.4" dependency from
crates/hotkey/Cargo.toml. magnotia_hotkey=info filter target was
already added to init_tracing in 184214b, so these events emit at the
default level immediately.

Verification:
- cargo fmt --all -- --check
- cargo check -p magnotia-hotkey — clean
- cargo check -p magnotia — clean
- rg 'log::|eprintln!' crates/hotkey/src/ src-tauri/src/commands/ crates/ai-formatting/src/ — zero hits

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:34:26 +01:00
184214b60a agent: engine slop residuals B — eprintln → tracing sweep
Replaces 22 production eprintln! sites with structured tracing events
across 8 files. Closes Area B of the post-prognosis residuals plan
(docs/superpowers/plans/2026-05-12-engine-slop-residuals.md).

Files touched (22 sites):
- crates/hotkey/src/linux.rs (2) — hotplug watcher degraded-mode warnings
- crates/ai-formatting/src/pipeline.rs (1) — LLM cleanup fallback warning
- src-tauri/src/commands/transcription.rs (1) — chunking dispatch info
- src-tauri/src/commands/diagnostics.rs (1) — crashes-dir setup warning
- src-tauri/src/commands/tasks.rs (1) — malformed feedback row warning
- src-tauri/src/commands/power.rs (3) — App Nap acquire/release/fail
- src-tauri/src/commands/models.rs (5) — Whisper warmup lifecycle
- src-tauri/src/commands/live.rs (8) — session start, chunk dispatch,
  per-chunk delivery, inference errors, worker disconnects, listener
  loss, status-channel cascade

Levels: error for unrecoverable failures (inference disconnect, panic,
status cascade), warn for recoverable degradation (LLM fallback,
malformed rows, App Nap fail, hotplug watcher fail), info for lifecycle
(session start, chunk processed, App Nap acquire/release, warmup
complete, chunking dispatch), debug for per-chunk noise (speech-gate
skip, chunk dispatch).

Two new dependencies and two new filter targets:
- tracing = "0.1" added to crates/hotkey and crates/ai-formatting
- Default EnvFilter in src-tauri/src/lib.rs::init_tracing extended with
  magnotia_hotkey=info,magnotia_ai_formatting=info so the new targets
  emit at the default level

Out of scope (intentional, left as-is):
- crates/mcp/src/main.rs — CLI binary, stderr is the log contract
  (module docstring) so the JSON-RPC stdout stream stays clean
- crates/*/tests/*.rs and crates/core/examples/tuning_log_demo.rs —
  test/example diagnostic output relies on --nocapture stdio semantics

Discovery during sweep (not fixed — separate follow-up): hotkey crate
has 6 existing log:: calls (log::error/warn/info/debug) but the
workspace builds tracing-subscriber without the tracing-log feature, so
those events are currently silent. Worth a follow-up to either add the
tracing-log bridge or migrate hotkey's existing log:: calls to
tracing::.

Verification:
- cargo fmt --all
- cargo check --workspace --all-targets — clean
- cargo test --workspace — 330+ tests, zero failures
- rg eprintln! src-tauri/src/commands/ crates/hotkey/src/ crates/ai-formatting/src/ — zero hits

Pre-existing working-tree churn in crates/llm/, src/lib/pages/,
src/lib/utils/saveMarkdown.ts and the untracked phase10a dogfood notes
deliberately left unstaged per Jake's instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:27:06 +01:00
792fb5ea08 agent: dev launcher — own Linux env-var contract, add dev:tauri, doc sweep
The 2026-05-12 engine-slop pass removed runtime std::env::set_var mutation from src-tauri/src/lib.rs and replaced it with warn_if_x11_env_unset_on_wayland. With no launcher owning the contract, Linux Wayland dogfood was about to regress.

run.sh:
- now owns Linux launcher env defaults via case "$(uname -s)"
  LIBCLANG_PATH=/usr/lib64/llvm21/lib64 (user-set wins)
  WEBKIT_DISABLE_DMABUF_RENDERER=1 (always on Linux; user-set wins)
  GDK_BACKEND=x11, WINIT_UNIX_BACKEND=x11 (Wayland only; user-set wins)
- 60s Vite readiness timeout
- detects early Vite exit (kill -0 + wait) instead of hanging forever
- trap installed before wait so Ctrl-C cleans up
- args forwarded: ./run.sh --release etc.
- non-exec final Tauri launch preserved so cleanup trap fires

package.json:
- "dev:tauri": "./run.sh" — canonical discoverable dev command

Docs:
- README, dev-setup, architecture-map runtime + launcher pages updated with the new contract; canonical command is npm run dev:tauri (./run.sh as direct equivalent)
- dev-launcher-and-scripts.md replaces the incorrect "kills process group" claim with honest "kills the spawned npm process"
- dev-setup.md path /CORBEL-Projects/magnotia → /CORBEL-Projects/transcription-app
- gpu-tuning/plan.md gets a superseded note rather than rewriting the original rationale
- engine-slop-residuals.md gains Area F (packaged-binary launcher contract) with honest wrapper-vs-.desktop trade-off documented

Verification: bash -n run.sh; shellcheck clean; cargo check -p magnotia green; npm pkg get 'scripts.dev:tauri' returns "./run.sh"; stale-ref sweep clean across living docs.
2026-05-12 22:05:33 +01:00
db654deecc agent: engine slop pass — DSP, typed errors, regex parsing, tracing, audit fixes
External code review on 2026-05-12 rated the codebase 4/10 across audio DSP, error typing, JS injection, env-var safety, ALSA parsing, and async logging. This commit lands the prognosis-level fixes plus three audit follow-ups.

Audio/DSP:
- StreamingResampler/rubato confirmed in the live capture path
- regression test at 12 kHz (rms < 0.01, ~40 dB) catches naive decimation
- near-Nyquist test at 9 kHz (rms < 0.05, ~26 dB) exercises transition band

Core errors:
- Other(String) removed; ProviderNotRegistered introduced
- Io variant restructured as struct with kind/message/raw_os_error
- FileNotFound display quotes paths
- Configuration variant removed (unused)

Core types:
- ModelId, EngineName backed by Cow<'static, str>; const borrowed ctor
- Megabytes::from_gb takes u64 (was f64)
- AudioSamples::sample_rate is NonZeroU32; zero-rate defensive branch removed

Capture:
- /proc/asound/cards parsing rewritten as anchored regex (OnceLock)
- regression test covers product names with embedded colons
- monitor_pattern_detection test restored alongside the regex test
- DEAD_SILENCE_FLOOR promoted to module-level with rationale
- DEVICE_VALIDATION_MS, SILENCE_RMS_FLOOR documented with field-observation rationale
- RMS validation loop made idiomatic
- eprintln! migrated to tracing with structured fields and targets

Tauri startup:
- unsafe std::env::set_var removed; ensure_x11_on_wayland renamed to warn_if_x11_env_unset_on_wayland (launcher/wrapper owns env-var contract)
- DB init + log prune + preferences load collapsed to one block_on
- build_preferences_script rewrites JS injection from JSON.parse string to direct object literal plus malformed-JSON guard and unit tests
- WebKitGTK microphone auto-grant logs warning at startup
- tracing subscriber initialised at top of run() (warn,magnotia=info,... on stderr; honors RUST_LOG); previously eprintln→tracing migration was silent because no subscriber existed

Filename counter:
- RECORDING_COUNTER uses SeqCst

Tests: cargo test --workspace --lib green (322 passed, 0 failed across 10 crates).

Three independent audits (original cleanup → Wren → fresh Codex subagent) concur on no critical findings.

Deferred to docs/superpowers/plans/2026-05-12-engine-slop-residuals.md: storage-layer typed errors, remaining eprintln→tracing sweep, capture actor-model refactor, property-based DSP testing, frontend/backend error boundary cleanup.
2026-05-12 22:03:58 +01:00
b463c32f17 chore: stabilize current head before Phase 10a QC 2026-05-10 23:00:25 +01:00
c95a5da077 docs(roadmap): bookmark SIE + adjacent embeddings/rerank/extract servers for PKM phase
Wyrdnote scales from voice-first dictation today to local-first
semantic PKM workbench tomorrow per the engine architecture spec.
The PKM phase needs a serving stack for embeddings + reranking +
entity extraction; this note bookmarks SIE (Superlinked Inference
Engine, Apache-2.0) as the trigger candidate plus six adjacents
to bake off against when the PKM phase lands.

Deferred because (1) PKM sits post-public-beta and infrastructure
decisions today would generalise poorly to a Q4 2026 selection;
(2) the candidates evolve fast; (3) Wyrdnote's local-first +
single-consumer-GPU constraint rules out a today-style bake-off
without real PKM-shape workload to test against.

Decision dimensions captured for the future evaluator: self-host
footprint, cold-start latency, throughput, quality on a Wyrdnote
eval set, reranker availability, extract/NER capability,
OEM-licensability against AGPL-3.0+dual-licence stack, telemetry
posture. Re-evaluation triggers documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:27:32 +01:00
6bc8acccce feat(transcription): Phase A — engine protocol + registry + orchestrator (clean-room)
Introduces the architectural spine for the Wyrdnote engine layer per
the spec at outputs/wyrdnote/2026-05-10-engine-architecture-spec.md
in the CORBEL-Main workspace, Codex-reviewed 2026/05/10.

Clean-room derivative of VoiceInk's TranscriptionService /
TranscriptionServiceRegistry / TranscriptionPipeline shape. Built from
the architectural pattern only; no GPL-3.0 code, comments, or
identifiers lifted. Wyrdnote names, control flow, and trait signatures
are original.

What this lands:

crates/cloud-providers/src/provider.rs (new, 184 lines)
  TranscriptionProvider async trait. Object-safe via async_trait so
  Arc<dyn TranscriptionProvider> is the canonical shape. Lives in
  cloud-providers (not transcription) per D7 so an OEM licensee
  implementing the trait depends only on this crate plus magnotia-core
  (no transcription internals leaked through the trait surface).
  Surrounding types: ProviderId (lower-kebab-case), ProviderKind,
  NetworkRequirement, CostClass, ProviderCapabilities, EngineProfile,
  ProviderTranscript. Compile-time object-safety witness in tests.

crates/cloud-providers/Cargo.toml (modified)
  Adds async-trait + serde dependencies.

crates/cloud-providers/src/lib.rs (modified)
  Re-exports the provider surface.

crates/transcription/src/registry.rs (new, 183 lines)
  EngineRegistry: catalogue of providers keyed by ProviderId. Push-style
  registration, default-provider id captured at construction. Read-only
  after boot. Full unit-test coverage: empty default, register/get
  round-trip, default-resolves-after-registration, re-register replaces,
  ids enumeration.

crates/transcription/src/orchestrator.rs (new, 286 lines)
  LocalProviderAdapter wraps Arc<LocalEngine>, presents async
  TranscriptionProvider upward via tokio::task::spawn_blocking. Per D7
  the adapter lives in the orchestrator, NOT as impl TranscriptionProvider
  for LocalEngine — this keeps the dependency edge one-directional and
  avoids leaking async-runtime requirements onto the synchronous
  Transcriber trait.

  Orchestrator: single transcribe() entry point; resolves a provider
  from the registry, derives TranscriptionOptions from the EngineProfile,
  dispatches. Returns a clear error when an unregistered provider is
  named. Unit tests use a mock CannedProvider to validate dispatch,
  error handling, and option routing without booting a real model.

crates/transcription/Cargo.toml (modified)
  Depends on magnotia-cloud-providers + async-trait. Dev-dep tokio
  gains macros + rt-multi-thread features for #[tokio::test].

crates/transcription/src/lib.rs (modified)
  Exports Orchestrator, EngineRegistry, LocalProviderAdapter, and
  re-exports the provider trait surface so downstream crates depend
  only on magnotia-transcription for both local engines and the trait.

KNOWN-ISSUES.md (modified)
  Adds KI-06: existing dictation, live, and meeting commands still call
  LocalEngine::transcribe_sync directly via pick_engine. The orchestrator
  path is dormant until a Phase A.1 follow-up commit migrates the call
  sites. Cloud providers (Phase G) cannot be exercised end-to-end until
  the rewire lands. Phasing rationale: this commit lands the abstractions
  cleanly with full test coverage; the rewire is a separate diff so the
  chunking + post-processing logic in transcribe_file moves without
  inflating this commit beyond review fidelity.

Test results: 9 new tests pass (orchestrator: dispatches, errors-on-
unregistered, routes-initial-prompt, object-safe; registry: empty-default,
round-trip, default-resolves, re-register, ids-list). Pre-existing
59 transcription tests + 5 cloud-providers tests still green.
cargo check --workspace clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:49:24 +01:00
308 changed files with 5588 additions and 2410 deletions

324
Cargo.lock generated
View File

@@ -2705,9 +2705,9 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]] [[package]]
name = "llama-cpp-2" name = "llama-cpp-2"
version = "0.1.145" version = "0.1.146"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e82b8c7a1c1a0ad97e1cc5cc28e01e9e14be73d4068e0fe9ac9d6c465001323" checksum = "f3b0f368c76cc0fe475e8257aeeec269e0d6569bd48b1f503efd0963fc3ee397"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"enumflags2", "enumflags2",
@@ -2719,9 +2719,9 @@ dependencies = [
[[package]] [[package]]
name = "llama-cpp-sys-2" name = "llama-cpp-sys-2"
version = "0.1.145" version = "0.1.146"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1e5495433ca7487b9f8c7046f64e69937861d438b20f12ee3c524f35d55ad3" checksum = "9b291e4bc2d10c43cd8dec16d49b6104cb3cb125f596ec380a753a5db1d965dd"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cc", "cc",
@@ -2752,6 +2752,169 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 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-subscriber",
"uuid",
"webkit2gtk",
]
[[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 = [
"log",
"lumotia-core",
"serde",
"sqlx",
"thiserror 1.0.69",
"tokio",
"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]] [[package]]
name = "lzma-rust2" name = "lzma-rust2"
version = "0.15.7" version = "0.15.7"
@@ -2785,159 +2948,6 @@ dependencies = [
"libc", "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",
"uuid",
"webkit2gtk",
]
[[package]]
name = "magnotia-ai-formatting"
version = "0.1.0"
dependencies = [
"magnotia-core",
"magnotia-llm",
"regex-lite",
]
[[package]]
name = "magnotia-audio"
version = "0.1.0"
dependencies = [
"cpal",
"hound",
"magnotia-core",
"rubato",
"serde",
"symphonia",
"tokio",
]
[[package]]
name = "magnotia-cloud-providers"
version = "0.1.0"
dependencies = [
"magnotia-core",
]
[[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",
"log",
"magnotia-core",
"nix 0.29.0",
"notify",
"serde",
"tokio",
]
[[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",
"tokio",
"uuid",
]
[[package]]
name = "magnotia-transcription"
version = "0.1.0"
dependencies = [
"futures-util",
"magnotia-core",
"num_cpus",
"reqwest 0.12.28",
"sha2",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
"transcribe-rs",
"whisper-rs",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.14.1" version = "0.14.1"

View File

@@ -1,11 +1,11 @@
--- ---
name: handover-2026-04-25 name: handover-2026-04-25
type: reference 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 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). > **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 ## 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 ## What shipped this session
### 9a — Export plumbing ### 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). - `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 `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. - 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 ### 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`). - `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 `MAGNOTIA_LLM_TEST_MODEL` matching the Phase 8 pattern. - `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. - `extract_content_tags_cmd` Tauri wrapper bridges through `state.llm_engine` with the standard `spawn_blocking` + `PowerAssertion` guard.
### 9b structural — migration v14 + persistence wiring ### 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. 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 ''`. - 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. - `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`. - 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. - `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 fmt --check`: clean.
- `cargo clippy --all-targets -- -D warnings`: 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 check`: 0 errors, 0 warnings across 3957 files.
- `npm run build`: clean production build via `@sveltejs/adapter-static`. - `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`: 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<LlmEngine>` is direct, not behind a `RwLock`. 2. `AppState.llm_engine: Arc<LlmEngine>` 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. 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) ## 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. - 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. - 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). - 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. | | 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 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 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. | | Phase 10c | Release: 0.1.0 version sync, CHANGELOG seeded from roadmap phases, release notes, tag + push. Half day. |
### Release-blocker state ### 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) - 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) - 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) - 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) - 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 - Active-focus upstream: `context/active-focus.md` in CORBEL-Main

View File

@@ -36,7 +36,7 @@ Tracked limitations and partial implementations in the current codebase. Each en
## Cloud providers ## 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. **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.
@@ -64,6 +64,20 @@ Tracked limitations and partial implementations in the current codebase. Each en
**Workaround:** N/A. **Workaround:** N/A.
## Engine architecture (Phase A)
### KI-06 — `transcribe_file` and live-transcription commands not yet rewired through `Orchestrator`
**Status:** Phase A introduced the `TranscriptionProvider` async trait ([`crates/cloud-providers/src/provider.rs`](crates/cloud-providers/src/provider.rs)), `EngineRegistry` ([`crates/transcription/src/registry.rs`](crates/transcription/src/registry.rs)), and `Orchestrator` plus `LocalProviderAdapter` ([`crates/transcription/src/orchestrator.rs`](crates/transcription/src/orchestrator.rs)). The abstractions compile, are object-safe, and pass unit tests against a mock provider. The existing dictation path ([`src-tauri/src/commands/transcription.rs`](src-tauri/src/commands/transcription.rs)) still calls `LocalEngine::transcribe_sync` directly via `pick_engine`, not through the orchestrator. Live and meeting commands likewise route around the orchestrator.
**Source:** [`src-tauri/src/commands/transcription.rs:29`](src-tauri/src/commands/transcription.rs#L29) (`pick_engine`), [`src-tauri/src/commands/live.rs`](src-tauri/src/commands/live.rs), [`src-tauri/src/commands/meeting.rs`](src-tauri/src/commands/meeting.rs).
**Impact:** None at runtime. The orchestrator path is dormant until a follow-up commit migrates the call sites. Cloud providers (Phase G) cannot be exercised end-to-end until this rewire lands.
**Resolution (deferred):** Phase A.1 follow-up commit. Build an `EngineRegistry` at app boot (one `LocalProviderAdapter` per `LocalEngine`), inject `Arc<Orchestrator>` into `AppState`, replace `pick_engine` plus `engine.transcribe_sync` chunking-loop calls with `orchestrator.transcribe(audio, &profile)` per chunk. Keep the chunking strategy in the command layer (it is a strategy concern, not a dispatch concern). Tests should cover both paths against a mock provider before touching the real engines.
**Workaround:** N/A. Existing path works as before.
## How to add an entry ## How to add an entry
When shipping a partial implementation or known limitation, add a `KI-NN` entry here with the four standard fields: **Status**, **Source** (file:line), **Impact**, **Workaround**. Link from the affected module's doc comment back to this file by ID. When shipping a partial implementation or known limitation, add a `KI-NN` entry here with the four standard fields: **Status**, **Source** (file:line), **Impact**, **Workaround**. Link from the affected module's doc comment back to this file by ID.

View File

@@ -1,8 +1,8 @@
# Magnotia # Lumotia
*Think out loud. Keep working.* *Think out loud. Keep working.*
Magnotia is a local-first, cognitive-load-aware dictation and task-capture desktop app. Every transcription, LLM cleanup, and task extraction runs on the user's machine. No telemetry, no analytics, no cloud dependency. The app is designed around a single observation: people who think in bursts lose ideas faster than they can type, and the tool's job is to get out of the way. Lumotia is a local-first, cognitive-load-aware dictation and task-capture desktop app. Every transcription, LLM cleanup, and task extraction runs on the user's machine. No telemetry, no analytics, no cloud dependency. The app is designed around a single observation: people who think in bursts lose ideas faster than they can type, and the tool's job is to get out of the way.
--- ---
@@ -21,15 +21,15 @@ Magnotia is a local-first, cognitive-load-aware dictation and task-capture deskt
1. **Local-first is the floor, not a feature.** No voice, transcript, or task ever leaves the user's machine unless they explicitly send it. No telemetry. 1. **Local-first is the floor, not a feature.** No voice, transcript, or task ever leaves the user's machine unless they explicitly send it. No telemetry.
2. **Cognitive load is the limiting resource.** Every new setting must earn its mental real estate. Every interaction should reduce, not add, decisions. 2. **Cognitive load is the limiting resource.** Every new setting must earn its mental real estate. Every interaction should reduce, not add, decisions.
3. **Composable, not monolithic.** Magnotia is a dictation primitive: via MCP, CLI, and filesystem export, it slots into whatever workflow the user already has (Obsidian, Claude Desktop, Cline, any text field). 3. **Composable, not monolithic.** Lumotia is a dictation primitive: via MCP, CLI, and filesystem export, it slots into whatever workflow the user already has (Obsidian, Claude Desktop, Cline, any text field).
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. 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. 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/magnotia-context.md`](docs/whisper-ecosystem/magnotia-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).
--- ---
## What Magnotia does today ## What Lumotia does today
### Speech-to-text ### Speech-to-text
- Vulkan-accelerated local **Whisper** inference via [whisper-rs](https://github.com/tazz4843/whisper-rs) 0.16 + whisper.cpp. Works on NVIDIA, AMD, Intel Arc, Apple (via MoltenVK), and integrated graphics. - Vulkan-accelerated local **Whisper** inference via [whisper-rs](https://github.com/tazz4843/whisper-rs) 0.16 + whisper.cpp. Works on NVIDIA, AMD, Intel Arc, Apple (via MoltenVK), and integrated graphics.
@@ -65,7 +65,7 @@ These are enforced in the codebase (where practical) and in the docs under [`doc
- Transcript editor window (`/viewer`) with debounced autosave. - Transcript editor window (`/viewer`) with debounced autosave.
### External integration ### External integration
- **MCP stdio server** (`magnotia-mcp`) exposing read-only transcripts and tasks to any Model Context Protocol client (Claude Desktop, Cline, Cursor, etc.). No authentication, read-only, local-only. - **MCP stdio server** (`lumotia-mcp`) exposing read-only transcripts and tasks to any Model Context Protocol client (Claude Desktop, Cline, Cursor, etc.). No authentication, read-only, local-only.
### Accessibility ### Accessibility
- Dyslexia-friendly fonts bundled: Lexend, Atkinson Hyperlegible Next, OpenDyslexic. - Dyslexia-friendly fonts bundled: Lexend, Atkinson Hyperlegible Next, OpenDyslexic.
@@ -84,7 +84,7 @@ These are enforced in the codebase (where practical) and in the docs under [`doc
## Architecture ## Architecture
Magnotia is a Tauri 2 desktop app with three layers: Lumotia is a Tauri 2 desktop app with three layers:
``` ```
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
@@ -102,18 +102,18 @@ Magnotia is a Tauri 2 desktop app with three layers:
│ window-state │ │ window-state │
├─────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────┤
│ Rust workspace (crates/) │ │ Rust workspace (crates/) │
magnotia-core, magnotia-audio, magnotia-transcription, magnotia-llm, │ lumotia-core, lumotia-audio, lumotia-transcription, lumotia-llm, │
magnotia-ai-formatting, magnotia-storage, magnotia-hotkey, │ lumotia-ai-formatting, lumotia-storage, lumotia-hotkey, │
magnotia-cloud-providers, magnotia-mcp │ lumotia-cloud-providers, lumotia-mcp │
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
``` ```
The Rust workspace is the brain; Tauri is the OS integration surface; Svelte is the UI. The MCP server (`magnotia-mcp`) is a separate binary that opens Magnotia's SQLite store read-only — it's Magnotia-as-primitive for external agents. The Rust workspace is the brain; Tauri is the OS integration surface; Svelte is the UI. The MCP server (`lumotia-mcp`) is a separate binary that opens Lumotia's SQLite store read-only — it's Lumotia-as-primitive for external agents.
### Repository layout ### Repository layout
``` ```
magnotia/ lumotia/
├── Cargo.toml # workspace root ├── Cargo.toml # workspace root
├── src-tauri/ # Tauri app (main binary + commands) ├── src-tauri/ # Tauri app (main binary + commands)
│ ├── src/ │ ├── src/
@@ -165,15 +165,15 @@ magnotia/
| Crate | Responsibility | | Crate | Responsibility |
|---|---| |---|---|
| **`magnotia-core`** | Shared types (`Segment`, `Transcript`, `Megabytes`, `ModelId`), constants, the `Engine` / `SpeedTier` / `AccuracyTier` enums, hardware probe (`sysinfo`-based), model registry (Whisper + Parakeet entries), hardware-aware recommendation scoring, `process_watch` for meeting detection. | | **`lumotia-core`** | Shared types (`Segment`, `Transcript`, `Megabytes`, `ModelId`), constants, the `Engine` / `SpeedTier` / `AccuracyTier` enums, hardware probe (`sysinfo`-based), model registry (Whisper + Parakeet entries), hardware-aware recommendation scoring, `process_watch` for meeting detection. |
| **`magnotia-audio`** | `cpal`-based microphone capture with device hotplug + error forwarding, VAD, `rubato` streaming resampler to 16 kHz mono, `symphonia` file decoding, `hound` WAV I/O. | | **`lumotia-audio`** | `cpal`-based microphone capture with device hotplug + error forwarding, VAD, `rubato` streaming resampler to 16 kHz mono, `symphonia` file decoding, `hound` WAV I/O. |
| **`magnotia-transcription`** | `whisper-rs` backend (`WhisperRsBackend`) that owns a `WhisperContext` and supports `set_initial_prompt`. `LocalEngine` wraps both Whisper and Parakeet (via `transcribe-rs` ONNX) behind a common `Transcriber` trait. Streaming primitives (`VadChunker`, `LocalAgreement`, buffer trim) live in the `streaming/` module. Model manager handles downloads, paths, and disk checks. | | **`lumotia-transcription`** | `whisper-rs` backend (`WhisperRsBackend`) that owns a `WhisperContext` and supports `set_initial_prompt`. `LocalEngine` wraps both Whisper and Parakeet (via `transcribe-rs` ONNX) behind a common `Transcriber` trait. Streaming primitives (`VadChunker`, `LocalAgreement`, buffer trim) live in the `streaming/` module. Model manager handles downloads, paths, and disk checks. |
| **`magnotia-llm`** | `llama-cpp-2` engine with a four-tier Qwen3.5 / Qwen3.6 model manager. Three high-level surfaces: `cleanup_text` (formatting), `decompose_task` (37 micro-steps, GBNF-constrained JSON array), `extract_tasks` (optional-array, GBNF-constrained). Resumable HTTP downloads with SHA-256 verify. | | **`lumotia-llm`** | `llama-cpp-2` engine with a four-tier Qwen3.5 / Qwen3.6 model manager. Three high-level surfaces: `cleanup_text` (formatting), `decompose_task` (37 micro-steps, GBNF-constrained JSON array), `extract_tasks` (optional-array, GBNF-constrained). Resumable HTTP downloads with SHA-256 verify. |
| **`magnotia-ai-formatting`** | Post-processing pipeline: filler removal, British English conversion, anti-hallucination filter, smart paragraph breaks on long pauses, optional LLM cleanup. Also hosts the `llm_client::CLEANUP_PROMPT` constant (prompt-injection-hardened). | | **`lumotia-ai-formatting`** | Post-processing pipeline: filler removal, British English conversion, anti-hallucination filter, smart paragraph breaks on long pauses, optional LLM cleanup. Also hosts the `llm_client::CLEANUP_PROMPT` constant (prompt-injection-hardened). |
| **`magnotia-storage`** | SQLite via `sqlx` 0.8. Migrations, CRUD for transcripts / tasks / subtasks / profiles / profile terms / settings / error log, FTS5 search, file-storage paths. | | **`lumotia-storage`** | SQLite via `sqlx` 0.8. Migrations, CRUD for transcripts / tasks / subtasks / profiles / profile terms / settings / error log, FTS5 search, file-storage paths. |
| **`magnotia-hotkey`** | Linux `evdev` hotkey listener with device hotplug. Parses Tauri-style hotkey strings (`Ctrl+Shift+R`), emits Pressed / Released events. Works natively on Wayland (no X11 dependency). Checks `/dev/input/event*` access on startup; surfaces a clear "add yourself to the `input` group" error when missing. | | **`lumotia-hotkey`** | Linux `evdev` hotkey listener with device hotplug. Parses Tauri-style hotkey strings (`Ctrl+Shift+R`), emits Pressed / Released events. Works natively on Wayland (no X11 dependency). Checks `/dev/input/event*` access on startup; surfaces a clear "add yourself to the `input` group" error when missing. |
| **`magnotia-cloud-providers`** | BYOK cloud-STT provider stubs. Currently empty scaffolding. When populated: OpenAI-compatible endpoint + Anthropic (ceiling for scope). | | **`lumotia-cloud-providers`** | BYOK cloud-STT provider stubs. Currently empty scaffolding. When populated: OpenAI-compatible endpoint + Anthropic (ceiling for scope). |
| **`magnotia-mcp`** | Standalone `magnotia-mcp` binary implementing the MCP stdio protocol (2024-11-05). Read-only tools: `list_transcripts`, `get_transcript`, `search_transcripts`, `list_tasks`. Opens Magnotia's SQLite store. | | **`lumotia-mcp`** | Standalone `lumotia-mcp` binary implementing the MCP stdio protocol (2024-11-05). Read-only tools: `list_transcripts`, `get_transcript`, `search_transcripts`, `list_tasks`. Opens Lumotia's SQLite store. |
### Tauri commands (src-tauri/src/commands/) ### Tauri commands (src-tauri/src/commands/)
@@ -271,20 +271,22 @@ See [`docs/dev-setup.md`](docs/dev-setup.md) for the authoritative per-platform
### Dev launch ### Dev launch
The fast path — starts Vite, waits for port 1420, then launches Tauri: Canonical full-stack dev launch — starts Vite, waits for port 1420, then launches Tauri:
```bash
npm run dev:tauri
```
Direct shell equivalent:
```bash ```bash
./run.sh ./run.sh
``` ```
Or manually: For pure frontend iteration without Tauri:
```bash ```bash
# Terminal 1
npm run dev:frontend npm run dev:frontend
# Terminal 2
npm run tauri dev
``` ```
### Build ### Build
@@ -311,24 +313,24 @@ Beyond this README, the repo ships extensive internal documentation:
### Product + strategy — `docs/brief/` ### Product + strategy — `docs/brief/`
Research briefs, competitive analysis, and strategic framing. Start with: Research briefs, competitive analysis, and strategic framing. Start with:
- [`what-magnotia-is.md`](docs/brief/what-magnotia-is.md) — product thesis - [`what-lumotia-is.md`](docs/brief/what-lumotia-is.md) — product thesis
- [`why-current-tools-fail.md`](docs/brief/why-current-tools-fail.md) — market gap - [`why-current-tools-fail.md`](docs/brief/why-current-tools-fail.md) — market gap
- [`design-principles.md`](docs/brief/design-principles.md) — full principle list - [`design-principles.md`](docs/brief/design-principles.md) — full principle list
- [`target-audience.md`](docs/brief/target-audience.md), [`market-size-demographics.md`](docs/brief/market-size-demographics.md) - [`target-audience.md`](docs/brief/target-audience.md), [`market-size-demographics.md`](docs/brief/market-size-demographics.md)
- Appendices on cognitive ergonomics, AI body doubling, evolutionary psychology, implementation intentions, HITL scaffolding, voice interfaces - Appendices on cognitive ergonomics, AI body doubling, evolutionary psychology, implementation intentions, HITL scaffolding, voice interfaces
### Brand — `docs/brand/` ### Brand — `docs/brand/`
- [`magnotia-brand-guidelines.md`](docs/brand/magnotia-brand-guidelines.md) - [`lumotia-brand-guidelines.md`](docs/brand/lumotia-brand-guidelines.md)
- [`magnotia-brand-platform.md`](docs/brand/magnotia-brand-platform.md) - [`lumotia-brand-platform.md`](docs/brand/lumotia-brand-platform.md)
### Technical research — `docs/whisper-ecosystem/` ### Technical research — `docs/whisper-ecosystem/`
Cross-repo survey of 10 OSS Whisper projects, the Magnotia-specific atomic task backlog, and the two Cursor workstream plans. Cross-repo survey of 10 OSS Whisper projects, the Lumotia-specific atomic task backlog, and the two Cursor workstream plans.
- [`brief.md`](docs/whisper-ecosystem/brief.md) — 31-item task backlog (the canonical research spec) - [`brief.md`](docs/whisper-ecosystem/brief.md) — 31-item task backlog (the canonical research spec)
- [`magnotia-context.md`](docs/whisper-ecosystem/magnotia-context.md) — ideology, shipped state, file-ownership fence for cloud AI agents - [`lumotia-context.md`](docs/whisper-ecosystem/lumotia-context.md) — ideology, shipped state, file-ownership fence for cloud AI agents
- [`workstream-A.md`](docs/whisper-ecosystem/workstream-A.md), [`workstream-B.md`](docs/whisper-ecosystem/workstream-B.md) — executed workstream plans - [`workstream-A.md`](docs/whisper-ecosystem/workstream-A.md), [`workstream-B.md`](docs/whisper-ecosystem/workstream-B.md) — executed workstream plans
### GPU tuning — `docs/gpu-tuning/` ### GPU tuning — `docs/gpu-tuning/`
- [`plan.md`](docs/gpu-tuning/plan.md) — MVP plan for GGML env-var panel + `magnotia-bench` auto-tuner + `magnotia-configs` community repo - [`plan.md`](docs/gpu-tuning/plan.md) — MVP plan for GGML env-var panel + `lumotia-bench` auto-tuner + `lumotia-configs` community repo
### Session handovers ### Session handovers
- [`HANDOVER.md`](HANDOVER.md) — latest session summary - [`HANDOVER.md`](HANDOVER.md) — latest session summary
@@ -350,7 +352,7 @@ Pinned roadmap items (scoped in docs and session memory):
- **Phase 4** — remaining items from [`workstream-A.md`](docs/whisper-ecosystem/workstream-A.md) + [`workstream-B.md`](docs/whisper-ecosystem/workstream-B.md) - **Phase 4** — remaining items from [`workstream-A.md`](docs/whisper-ecosystem/workstream-A.md) + [`workstream-B.md`](docs/whisper-ecosystem/workstream-B.md)
- **Voice calibration** — three-tier plan replacing the hardcoded speech-gate with per-user baselines - **Voice calibration** — three-tier plan replacing the hardcoded speech-gate with per-user baselines
- **GPU community tuning** — see [`docs/gpu-tuning/plan.md`](docs/gpu-tuning/plan.md); five-phase roadmap from settings panel to agentic auto-tuner + community config repo - **GPU community tuning** — see [`docs/gpu-tuning/plan.md`](docs/gpu-tuning/plan.md); five-phase roadmap from settings panel to agentic auto-tuner + community config repo
- **Cloud endpoint contract test** — when `magnotia-cloud-providers` grows a real provider - **Cloud endpoint contract test** — when `lumotia-cloud-providers` grows a real provider
- **`ggml` dedup** — replace the interim `-Wl,--allow-multiple-definition` link flag with a proper shared-lib setup; unblocks custom shader / backend work - **`ggml` dedup** — replace the interim `-Wl,--allow-multiple-definition` link flag with a proper shared-lib setup; unblocks custom shader / backend work
- **Mobile (iOS / Android)** — long-horizon, gated on the single-binary Rust stack scaling - **Mobile (iOS / Android)** — long-horizon, gated on the single-binary Rust stack scaling
@@ -358,7 +360,7 @@ Explicitly shelved (not coming without specific community signal):
- Wake-word / always-listening agent - Wake-word / always-listening agent
- Chat-style LLM UI - Chat-style LLM UI
- Multi-provider cloud fan-out beyond OpenAI-compatible + Anthropic - Multi-provider cloud fan-out beyond OpenAI-compatible + Anthropic
- Second notes-editing surface (transcripts leave Magnotia via frontmatter to Obsidian) - Second notes-editing surface (transcripts leave Lumotia via frontmatter to Obsidian)
- Speaker diarization - Speaker diarization
- Dragon-style passage-based speaker fine-tuning (Whisper has no speaker adaptation) - Dragon-style passage-based speaker fine-tuning (Whisper has no speaker adaptation)
@@ -385,4 +387,4 @@ To be finalised before public beta. Current intent: MIT or similar permissive li
## Contact ## Contact
**Jake Sames** — [jakeadriansames@gmail.com](mailto:jakeadriansames@gmail.com) **Jake Sames** — [jakeadriansames@gmail.com](mailto:jakeadriansames@gmail.com)
Repo: [github.com/jakejars/magnotia](https://github.com/jakejars/magnotia) · [git.corbel.consulting/jake/magnotia](https://git.corbel.consulting/jake/magnotia) Repo: [github.com/jakejars/lumotia](https://github.com/jakejars/lumotia) · [git.corbel.consulting/jake/lumotia](https://git.corbel.consulting/jake/lumotia)

View File

@@ -1,10 +1,11 @@
[package] [package]
name = "magnotia-ai-formatting" name = "lumotia-ai-formatting"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Magnotia" description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Lumotia"
[dependencies] [dependencies]
magnotia-core = { path = "../core" } lumotia-core = { path = "../core" }
magnotia-llm = { path = "../llm" } lumotia-llm = { path = "../llm" }
regex-lite = "0.1" regex-lite = "0.1"
tracing = "0.1"

View File

@@ -3,7 +3,7 @@
//! The llm_client is not yet wired to a running model. This module defines //! 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. //! 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. /// System prompt sent before every cleanup call.
/// ///
@@ -13,7 +13,7 @@ use magnotia_llm::{EngineError, LlmEngine};
/// Whispering's published baseline, directly counteracts the /// Whispering's published baseline, directly counteracts the
/// "LLM changed my meaning" failure mode: the model's job is to /// "LLM changed my meaning" failure mode: the model's job is to
/// translate spoken speech into well-formed written form — not 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 /// is the source of truth; cleanup is a translation pass, not a
/// rewrite. /// rewrite.
/// 2. **Prompt-injection hardening.** The guard ("speech, not /// 2. **Prompt-injection hardening.** The guard ("speech, not
@@ -161,7 +161,7 @@ pub fn cleanup_text(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use magnotia_llm::EngineError; use lumotia_llm::EngineError;
#[test] #[test]
fn empty_terms_returns_empty_string() { fn empty_terms_returns_empty_string() {
@@ -183,7 +183,7 @@ mod tests {
assert!(CLEANUP_PROMPT.contains("output ONLY the cleaned transcript")); 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 /// ideology — raw transcript is the source of truth, cleanup is a
/// translation pass. Drifting from this phrasing in a refactor would /// translation pass. Drifting from this phrasing in a refactor would
/// quietly open the door to the "LLM changed my meaning" failure /// quietly open the door to the "LLM changed my meaning" failure

View File

@@ -1,6 +1,6 @@
use magnotia_core::constants::SMART_PARAGRAPH_GAP_SECS; use lumotia_core::constants::SMART_PARAGRAPH_GAP_SECS;
use magnotia_core::types::Segment; use lumotia_core::types::Segment;
use magnotia_llm::LlmEngine; use lumotia_llm::LlmEngine;
use crate::{llm_client, rule_based, to_plain_text::to_plain_text}; use crate::{llm_client, rule_based, to_plain_text::to_plain_text};
@@ -91,8 +91,9 @@ pub fn post_process_segments(
replace_segments_with_cleaned(segments, cleaned.trim()); replace_segments_with_cleaned(segments, cleaned.trim());
} }
Ok(_) => {} Ok(_) => {}
Err(err) => eprintln!( Err(err) => tracing::warn!(
"[ai-formatting] LLM cleanup failed, keeping rule-based output: {err}" error = %err,
"LLM cleanup failed, keeping rule-based output"
), ),
} }
} }

View File

@@ -7,13 +7,13 @@
//! structure) degraded cleanup quality materially; plain-text input //! structure) degraded cleanup quality materially; plain-text input
//! raised it back. //! 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 //! `start`/`end` f64 fields carry the timing), so "timestamp
//! stripping" falls out of using the text field alone. The work here //! stripping" falls out of using the text field alone. The work here
//! is the whitespace pass and empty-segment filter, plus a single //! is the whitespace pass and empty-segment filter, plus a single
//! public function the pipeline can depend on. //! 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 /// Join transcription segments into a single plain-text string
/// suitable for feeding to an LLM cleanup prompt. /// suitable for feeding to an LLM cleanup prompt.

View File

@@ -1,11 +1,11 @@
[package] [package]
name = "magnotia-audio" name = "lumotia-audio"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Magnotia" description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Lumotia"
[dependencies] [dependencies]
magnotia-core = { path = "../core" } lumotia-core = { path = "../core" }
# Microphone capture # Microphone capture
cpal = "0.17" cpal = "0.17"
@@ -28,3 +28,5 @@ tokio = { version = "1", features = ["rt", "sync"] }
# Serde for DeviceInfo (returned across the Tauri boundary) # Serde for DeviceInfo (returned across the Tauri boundary)
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
tracing = "0.1"
regex = "1"

View File

@@ -4,22 +4,30 @@ use std::sync::Arc;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, Sample, SampleFormat, SizedSample}; use cpal::{FromSample, Sample, SampleFormat, SizedSample};
use regex::Regex;
use serde::{Deserialize, Serialize}; 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; const AUDIO_CHANNEL_CAPACITY: usize = 32;
/// Validation window. We listen for this long and compute RMS to decide /// Validation window. 350ms is long enough to collect several cpal callback
/// whether the chosen device is delivering real audio (vs a silent monitor). /// buffers at common 44.1/48kHz rates while keeping Settings/UI device
/// switching perceptibly sub-second.
const DEVICE_VALIDATION_MS: u64 = 350; const DEVICE_VALIDATION_MS: u64 = 350;
/// Below this RMS amplitude (peak ±1.0 scale) the input is treated as /// Below this RMS amplitude (peak ±1.0 scale) the input is treated as
/// silence. PulseAudio/PipeWire monitor sources for an idle speaker /// silence. Field dogfooding on PipeWire/PulseAudio showed idle monitor
/// typically deliver dead-zero samples; real microphones yield ~0.0005+ /// sources at exact or near-zero RMS, while connected microphones in quiet
/// even in a quiet room. Conservative floor: 1e-5. /// rooms stayed around 5e-4+; 1e-5 keeps a 50x safety margin below that.
const SILENCE_RMS_FLOOR: f32 = 1e-5; const SILENCE_RMS_FLOOR: f32 = 1e-5;
/// Absolute floor used even for monitor fallback. Values below this are
/// effectively digital zero on normalized f32 PCM, so accepting them only
/// records silence and hides device-routing failures.
const DEAD_SILENCE_FLOOR: f32 = 1e-7;
/// A chunk of captured audio from the microphone. /// A chunk of captured audio from the microphone.
pub struct AudioChunk { pub struct AudioChunk {
pub samples: Vec<f32>, pub samples: Vec<f32>,
@@ -53,7 +61,6 @@ pub struct DeviceInfo {
/// `start()` has already returned. The live session subscribes to these via /// `start()` has already returned. The live session subscribes to these via
/// `error_rx()` so the frontend can show a toast when the mic vanishes /// `error_rx()` so the frontend can show a toast when the mic vanishes
/// mid-recording. /// mid-recording.
/// (Codex review 2026/04/17 M2)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CaptureRuntimeError { pub struct CaptureRuntimeError {
pub device_name: String, pub device_name: String,
@@ -84,7 +91,6 @@ impl MicrophoneCapture {
/// Take the runtime-error receiver. Can be called once per capture; the /// Take the runtime-error receiver. Can be called once per capture; the
/// caller (live session manager) drains it on its own cadence and surfaces /// caller (live session manager) drains it on its own cadence and surfaces
/// errors to the frontend. Returns None on the second call. /// errors to the frontend. Returns None on the second call.
/// (Codex review 2026/04/17 M2)
pub fn take_error_rx(&mut self) -> Option<mpsc::Receiver<CaptureRuntimeError>> { pub fn take_error_rx(&mut self) -> Option<mpsc::Receiver<CaptureRuntimeError>> {
self.error_rx.take() self.error_rx.take()
} }
@@ -100,7 +106,7 @@ impl MicrophoneCapture {
let devices = host let devices = host
.input_devices() .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 // Load ALSA card descriptions once per enumeration. These are the
// "real" product names (e.g. "Blue Microphones") that cpal's // "real" product names (e.g. "Blue Microphones") that cpal's
@@ -138,17 +144,17 @@ impl MicrophoneCapture {
let host = cpal::default_host(); let host = cpal::default_host();
let devices = host let devices = host
.input_devices() .input_devices()
.map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))?; .map_err(|e| Error::AudioCaptureFailed(format!("input_devices: {e}")))?;
for device in devices { for device in devices {
let name = device_display_name(&device).unwrap_or_default(); let name = device_display_name(&device).unwrap_or_default();
if name == device_name { if name == device_name {
eprintln!("[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); 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. \ "Selected device '{device_name}' not found in current host enumeration. \
It may have been disconnected. Open Settings → Audio to pick another." It may have been disconnected. Open Settings → Audio to pick another."
))) )))
@@ -172,7 +178,7 @@ impl MicrophoneCapture {
let mut all_devices: Vec<cpal::Device> = host let mut all_devices: Vec<cpal::Device> = host
.input_devices() .input_devices()
.map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))? .map_err(|e| Error::AudioCaptureFailed(format!("input_devices: {e}")))?
.collect(); .collect();
// Sort: default first, then non-monitor, then monitor-as-last-resort. // Sort: default first, then non-monitor, then monitor-as-last-resort.
@@ -189,10 +195,11 @@ impl MicrophoneCapture {
} }
}); });
eprintln!( tracing::info!(
"[magnotia-audio] start: enumerated {} input device(s) (default='{}')", target: "lumotia_audio",
all_devices.len(), device_count = all_devices.len(),
default_name default = %default_name,
"enumerated input devices"
); );
// First pass: require real audio energy. // First pass: require real audio energy.
@@ -204,23 +211,25 @@ impl MicrophoneCapture {
match open_and_validate(device.clone(), &name, true) { match open_and_validate(device.clone(), &name, true) {
Ok(result) => return Ok(result), Ok(result) => return Ok(result),
Err(e) => { Err(e) => {
eprintln!("[magnotia-audio] '{name}' rejected: {e}"); tracing::warn!(target: "lumotia_audio", device = %name, error = %e, "candidate device rejected");
} }
} }
} }
// Second pass: accept anything that delivers bytes (monitor sources // Second pass: accept anything that delivers bytes (monitor sources
// included). Better to capture from a monitor than fail entirely. // included). Better to capture from a monitor than fail entirely.
eprintln!( tracing::warn!(
"[magnotia-audio] no non-monitor mic produced audio; falling back to monitor/loopback sources" target: "lumotia_audio",
"no non-monitor mic produced audio; falling back to monitor/loopback sources"
); );
for device in &all_devices { for device in &all_devices {
let name = device_display_name(device).unwrap_or_default(); let name = device_display_name(device).unwrap_or_default();
match open_and_validate(device.clone(), &name, false) { match open_and_validate(device.clone(), &name, false) {
Ok(result) => { Ok(result) => {
eprintln!( tracing::warn!(
"[magnotia-audio] FALLBACK: capturing from '{name}' (likely monitor source). \ target: "lumotia_audio",
Recordings may be silent or contain system audio." device = %name,
"capturing from likely monitor source; recordings may be silent or contain system audio"
); );
return Ok(result); return Ok(result);
} }
@@ -228,7 +237,7 @@ impl MicrophoneCapture {
} }
} }
Err(MagnotiaError::AudioCaptureFailed( Err(Error::AudioCaptureFailed(
"No working microphone found. Check that an input device is connected, \ "No working microphone found. Check that an input device is connected, \
that PulseAudio/PipeWire is running, and that the app has microphone permission. \ that PulseAudio/PipeWire is running, and that the app has microphone permission. \
Then open Settings → Audio to pick a device explicitly." Then open Settings → Audio to pick a device explicitly."
@@ -295,52 +304,49 @@ fn extract_card_id(name: &str) -> Option<&str> {
/// after the colon on that same line is the description we want. The /// after the colon on that same line is the description we want. The
/// next indented line is a longer location string we ignore. /// next indented line is a longer location string we ignore.
fn load_alsa_card_descriptions() -> std::collections::HashMap<String, String> { fn load_alsa_card_descriptions() -> std::collections::HashMap<String, String> {
use std::collections::HashMap;
let mut map = HashMap::new();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else { let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else {
return map; return std::collections::HashMap::new();
}; };
for line in contents.lines() { parse_alsa_card_descriptions(&contents)
// Header lines start with an optional leading space plus a }
// digit (the card ID, right-aligned to 2 chars for readable
// formatting). Continuation lines are indented beyond that. #[cfg(not(target_os = "linux"))]
let trimmed = line.trim_start(); {
if !trimmed std::collections::HashMap::new()
.chars() }
.next() }
.map(|c| c.is_ascii_digit())
.unwrap_or(false) fn parse_alsa_card_descriptions(contents: &str) -> std::collections::HashMap<String, String> {
{ use std::collections::HashMap;
continue;
} static CARD_LINE: OnceLock<Regex> = OnceLock::new();
let Some(open) = trimmed.find('[') else { let card_line = CARD_LINE.get_or_init(|| {
continue; Regex::new(r"^\s*\d+\s+\[([^\]]+)\]\s*:\s*(.+?)\s*$").expect("valid ALSA card-line regex")
}; });
let Some(close) = trimmed[open..].find(']') else {
continue; let mut map = HashMap::new();
}; for line in contents.lines() {
let short_name = trimmed[open + 1..open + close].trim().to_string(); let Some(captures) = card_line.captures(line) else {
if short_name.is_empty() { continue;
continue; };
} let Some(short_name) = captures.get(1).map(|m| m.as_str().trim()) else {
let after_bracket = &trimmed[open + close + 1..]; continue;
let Some(colon) = after_bracket.find(':') else { };
continue; if short_name.is_empty() {
}; continue;
// Format: "USB-Audio - Blue Microphones" }
// We keep everything after the " - " if present, otherwise let raw = captures
// the whole post-colon fragment. .get(2)
let raw = after_bracket[colon + 1..].trim(); .map(|m| m.as_str().trim())
let description = raw .unwrap_or_default();
.split(" - ") let description = raw
.nth(1) .split_once(" - ")
.map(|s| s.trim().to_string()) .map(|(_, product)| product.trim())
.unwrap_or_else(|| raw.to_string()); .unwrap_or(raw);
if !description.is_empty() { if !description.is_empty() {
map.insert(short_name, description); map.insert(short_name.to_string(), description.to_string());
}
} }
} }
map map
@@ -355,16 +361,18 @@ fn open_and_validate(
) -> Result<(MicrophoneCapture, mpsc::Receiver<AudioChunk>)> { ) -> Result<(MicrophoneCapture, mpsc::Receiver<AudioChunk>)> {
let config = device let config = device
.default_input_config() .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 sample_rate = config.sample_rate();
let channels = config.channels(); let channels = config.channels();
let format = config.sample_format(); let format = config.sample_format();
eprintln!( tracing::info!(
"[magnotia-audio] trying '{name}' ({sr}Hz, {ch}ch, {fmt:?})", target: "lumotia_audio",
sr = sample_rate, device = %name,
ch = channels, sample_rate,
fmt = format channels,
format = ?format,
"trying audio input device"
); );
let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY); let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY);
@@ -375,8 +383,7 @@ fn open_and_validate(
// and counted in `dropped_errors` so the symptom is visible in the // and counted in `dropped_errors` so the symptom is visible in the
// diagnostic bundle even when the listener has gone away. Errors // diagnostic bundle even when the listener has gone away. Errors
// beyond the cap are by definition redundant noise in a stream that // beyond the cap are by definition redundant noise in a stream that
// is already failing. (Codex review 2026/04/17 M2; capacity bump and // is already failing.
// drop logging added 2026/04/25 audit pass.)
let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(32); let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(32);
let dropped_errors = Arc::new(AtomicU64::new(0)); let dropped_errors = Arc::new(AtomicU64::new(0));
@@ -415,16 +422,16 @@ fn open_and_validate(
name.to_string(), name.to_string(),
), ),
other => { other => {
return Err(MagnotiaError::AudioCaptureFailed(format!( return Err(Error::AudioCaptureFailed(format!(
"unsupported sample format {other:?}" "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 stream
.play() .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. // Validation window: collect chunks for DEVICE_VALIDATION_MS, compute RMS.
let deadline = let deadline =
@@ -440,9 +447,11 @@ fn open_and_validate(
} }
match rx.recv_timeout(remaining) { match rx.recv_timeout(remaining) {
Ok(chunk) => { Ok(chunk) => {
for &s in &chunk.samples { sum_sq += chunk
sum_sq += (s as f64) * (s as f64); .samples
} .iter()
.map(|&s| (s as f64).powi(2))
.sum::<f64>();
total_samples += chunk.samples.len(); total_samples += chunk.samples.len();
collected.push(chunk); collected.push(chunk);
} }
@@ -451,19 +460,22 @@ fn open_and_validate(
} }
if total_samples == 0 { if total_samples == 0 {
return Err(MagnotiaError::AudioCaptureFailed( return Err(Error::AudioCaptureFailed(
"device delivered zero samples in validation window".into(), "device delivered zero samples in validation window".into(),
)); ));
} }
let rms = (sum_sq / total_samples as f64).sqrt() as f32; let rms = (sum_sq / total_samples as f64).sqrt() as f32;
eprintln!( tracing::info!(
"[magnotia-audio] '{name}' validation: {samples} samples, rms={rms:.6}", target: "lumotia_audio",
samples = total_samples device = %name,
samples = total_samples,
rms,
"audio input validation complete"
); );
if require_audio && rms < SILENCE_RMS_FLOOR { 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})" "device produced silence (rms={rms:.6} below floor {SILENCE_RMS_FLOOR:.6})"
))); )));
} }
@@ -471,10 +483,9 @@ fn open_and_validate(
// Even in the fallback pass (require_audio=false), reject completely // Even in the fallback pass (require_audio=false), reject completely
// dead-zero audio. PulseAudio/PipeWire will sometimes happily emit a // dead-zero audio. PulseAudio/PipeWire will sometimes happily emit a
// long stream of f32 zeros from a borked device — that is worse than // long stream of f32 zeros from a borked device — that is worse than
// failing fast. (Codex review 2026/04/17 D3) // failing fast.
const DEAD_SILENCE_FLOOR: f32 = 1e-7;
if rms < DEAD_SILENCE_FLOOR { 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})" "device produced dead silence (rms={rms:.6e} below absolute floor {DEAD_SILENCE_FLOOR:.6e})"
))); )));
} }
@@ -482,14 +493,13 @@ fn open_and_validate(
// Re-queue the collected chunks so downstream gets them. Count any // Re-queue the collected chunks so downstream gets them. Count any
// drops here against the same `dropped_chunks` counter so the live // drops here against the same `dropped_chunks` counter so the live
// session sees them and can warn the user. // session sees them and can warn the user.
// (Codex review 2026/04/17 M1)
for chunk in collected { for chunk in collected {
if requeue_tx.try_send(chunk).is_err() { if requeue_tx.try_send(chunk).is_err() {
dropped_chunks.fetch_add(1, Ordering::Relaxed); dropped_chunks.fetch_add(1, Ordering::Relaxed);
} }
} }
eprintln!("[magnotia-audio] selected microphone: '{name}'"); tracing::info!(target: "lumotia_audio", device = %name, "selected microphone");
Ok(( Ok((
MicrophoneCapture { MicrophoneCapture {
stream: Some(stream), stream: Some(stream),
@@ -528,18 +538,16 @@ where
sample_rate, sample_rate,
channels, channels,
}; };
// try_send fails if the channel is full. Track that explicitly // try_send fails if the channel is full. Track that explicitly;
// rather than swallowing it — Codex review 2026/04/17 caught // otherwise backpressure looks like clean transcription silence.
// this as a silent-failure risk under sustained load.
if tx.try_send(chunk).is_err() { if tx.try_send(chunk).is_err() {
dropped_chunks.fetch_add(1, Ordering::Relaxed); dropped_chunks.fetch_add(1, Ordering::Relaxed);
} }
}, },
move |err| { move |err| {
// Surface stream errors to the live session via err_tx so the // Surface stream errors to the live session via err_tx so the
// frontend can show a toast. Also keep the eprintln for ops // frontend can show a toast.
// logs. (Codex review 2026/04/17 M2) tracing::error!(target: "lumotia_audio", error = %err, "capture stream error");
eprintln!("[magnotia-audio] capture error: {err}");
if err_tx if err_tx
.try_send(CaptureRuntimeError { .try_send(CaptureRuntimeError {
device_name: err_device_name.clone(), device_name: err_device_name.clone(),
@@ -547,15 +555,15 @@ where
}) })
.is_err() .is_err()
{ {
// Channel full — listener has stalled or detached. Note // Channel full — listener has stalled or detached. Keep a
// it in stderr and the dropped-errors counter so the // counter so the diagnostic bundle still shows the symptom
// diagnostic bundle still shows the symptom even if the // even if the frontend never received the typed event.
// frontend never received the typed event.
let prior = dropped_errors.fetch_add(1, Ordering::Relaxed); let prior = dropped_errors.fetch_add(1, Ordering::Relaxed);
eprintln!( tracing::warn!(
"[magnotia-audio] capture error channel full; dropped error #{} for device '{}'", target: "lumotia_audio",
prior + 1, device = %err_device_name,
err_device_name, dropped_error = prior + 1,
"capture error channel full; dropping runtime error"
); );
} }
}, },
@@ -569,15 +577,43 @@ mod tests {
#[test] #[test]
fn monitor_pattern_detection() { fn monitor_pattern_detection() {
assert!(is_monitor_name( for name in [
"alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor",
)); "Monitor of Built-in Audio Analog Stereo",
assert!(is_monitor_name("Monitor of Built-in Audio Analog Stereo")); "PipeWire Loopback Source",
assert!(is_monitor_name("Some Loopback Device")); "Built-in Audio Monitor of Analog Stereo",
assert!(!is_monitor_name("Blue Yeti USB")); ] {
assert!(!is_monitor_name( assert!(is_monitor_name(name), "expected monitor source: {name}");
"alsa_input.pci-0000_00_1f.3.analog-stereo" }
));
assert!(!is_monitor_name("")); for name in [
"Built-in Audio Analog Stereo",
"Blue Microphones",
"HD Pro Webcam C920",
"sysdefault:CARD=Microphones",
] {
assert!(!is_monitor_name(name), "expected physical input: {name}");
}
}
#[test]
fn parses_alsa_cards_with_regex() {
let contents = r#"
2 [Microphones ]: USB-Audio - Blue Microphones
Blue Microphones at usb-0000:04:00.3-2.1, full speed
3 [C920 ]: USB-Audio - HD Pro Webcam C920: With Colon
HD Pro Webcam C920 at usb-0000:04:00.3-2.2, high speed
"#;
let parsed = parse_alsa_card_descriptions(contents);
assert_eq!(
parsed.get("Microphones").map(String::as_str),
Some("Blue Microphones")
);
assert_eq!(
parsed.get("C920").map(String::as_str),
Some("HD Pro Webcam C920: With Colon")
);
} }
} }

View File

@@ -1,7 +1,7 @@
use std::path::Path; use std::path::Path;
use magnotia_core::error::Result; use lumotia_core::error::Result;
use magnotia_core::types::AudioSamples; use lumotia_core::types::AudioSamples;
use crate::decode::decode_audio_file; use crate::decode::decode_audio_file;
use crate::resample::resample_to_16khz; use crate::resample::resample_to_16khz;
@@ -15,5 +15,7 @@ pub async fn decode_and_resample(path: &Path) -> Result<AudioSamples> {
resample_to_16khz(&audio) resample_to_16khz(&audio)
}) })
.await .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}"))
})?
} }

View File

@@ -9,13 +9,13 @@ use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions; use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint; use symphonia::core::probe::Hint;
use magnotia_core::error::{MagnotiaError, Result}; use lumotia_core::error::{Error, Result};
use magnotia_core::types::AudioSamples; use lumotia_core::types::AudioSamples;
/// Decode an audio file to mono f32 PCM samples. /// Decode an audio file to mono f32 PCM samples.
/// Supports all formats symphonia handles: mp3, aac, flac, wav, ogg, etc. /// 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 /// 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 /// error and skipped per-packet decode errors, so a truncated or corrupt
/// input silently returned `Ok` with whatever had decoded before the /// input silently returned `Ok` with whatever had decoded before the
@@ -29,7 +29,7 @@ pub fn decode_audio_file_limited(
max_duration_secs: Option<f64>, max_duration_secs: Option<f64>,
) -> Result<AudioSamples> { ) -> Result<AudioSamples> {
let file = File::open(path) let file = File::open(path)
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Cannot open file: {e}")))?; .map_err(|e| Error::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new(); let mut hint = Hint::new();
@@ -42,7 +42,7 @@ pub fn decode_audio_file_limited(
pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>> { pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>> {
let file = File::open(path) let file = File::open(path)
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Cannot open file: {e}")))?; .map_err(|e| Error::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new(); let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) { 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<Option<f64>> {
&FormatOptions::default(), &FormatOptions::default(),
&MetadataOptions::default(), &MetadataOptions::default(),
) )
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Unsupported format: {e}")))?; .map_err(|e| Error::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
let track = probed let track = probed
.format .format
.default_track() .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 let sample_rate = track
.codec_params .codec_params
.sample_rate .sample_rate
.ok_or_else(|| MagnotiaError::AudioDecodeFailed("Unknown sample rate".into()))?; .ok_or_else(|| Error::AudioDecodeFailed("Unknown sample rate".into()))?;
Ok(track Ok(track
.codec_params .codec_params
.n_frames .n_frames
@@ -86,20 +86,22 @@ fn decode_media_stream(
&FormatOptions::default(), &FormatOptions::default(),
&MetadataOptions::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 mut format = probed.format;
let track = format let track = format
.default_track() .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 let sample_rate = track
.codec_params .codec_params
.sample_rate .sample_rate
.ok_or_else(|| MagnotiaError::AudioDecodeFailed("Unknown sample rate".into()))?; .ok_or_else(|| Error::AudioDecodeFailed("Unknown sample rate".into()))?;
if sample_rate == 0 { 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; let track_id = track.id;
@@ -107,7 +109,7 @@ fn decode_media_stream(
let mut decoder = symphonia::default::get_codecs() let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default()) .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<f32> = Vec::new(); let mut samples: Vec<f32> = Vec::new();
@@ -121,12 +123,12 @@ fn decode_media_stream(
break; break;
} }
Err(SymphoniaError::ResetRequired) => { Err(SymphoniaError::ResetRequired) => {
return Err(MagnotiaError::AudioDecodeFailed( return Err(Error::AudioDecodeFailed(
"decoder reset required mid-stream — input contains a discontinuity".into(), "decoder reset required mid-stream — input contains a discontinuity".into(),
)); ));
} }
Err(e) => { Err(e) => {
return Err(MagnotiaError::AudioDecodeFailed(format!( return Err(Error::AudioDecodeFailed(format!(
"packet read failed: {e}" "packet read failed: {e}"
))); )));
} }
@@ -138,7 +140,7 @@ fn decode_media_stream(
let decoded = decoder let decoded = decoder
.decode(&packet) .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 spec = *decoded.spec();
let channels = spec.channels.count(); let channels = spec.channels.count();
@@ -158,7 +160,7 @@ fn decode_media_stream(
.map(|limit| samples.len() > limit) .map(|limit| samples.len() > limit)
.unwrap_or(false) .unwrap_or(false)
{ {
return Err(MagnotiaError::AudioDecodeFailed(format!( return Err(Error::AudioDecodeFailed(format!(
"Audio is longer than the {:.0} minute import limit", "Audio is longer than the {:.0} minute import limit",
max_duration_secs.unwrap_or(0.0) / 60.0 max_duration_secs.unwrap_or(0.0) / 60.0
))); )));
@@ -166,7 +168,9 @@ fn decode_media_stream(
} }
if samples.is_empty() { 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)) Ok(AudioSamples::new(samples, sample_rate, 1))
@@ -187,7 +191,7 @@ mod tests {
} }
fn valid_wav_bytes(sample_count: usize) -> Vec<u8> { fn valid_wav_bytes(sample_count: usize) -> Vec<u8> {
let path = temp_path("magnotia_decode_tmp_for_bytes.wav"); let path = temp_path("lumotia_decode_tmp_for_bytes.wav");
let samples: Vec<f32> = (0..sample_count).map(|i| (i as f32) / 1000.0).collect(); let samples: Vec<f32> = (0..sample_count).map(|i| (i as f32) / 1000.0).collect();
let audio = AudioSamples::mono_16khz(samples); let audio = AudioSamples::mono_16khz(samples);
write_wav(&path, &audio).unwrap(); write_wav(&path, &audio).unwrap();
@@ -234,7 +238,7 @@ mod tests {
#[test] #[test]
fn decodes_valid_wav_successfully() { fn decodes_valid_wav_successfully() {
let path = temp_path("magnotia_decode_valid.wav"); let path = temp_path("lumotia_decode_valid.wav");
let samples: Vec<f32> = (0..4_000).map(|i| (i as f32) / 1000.0).collect(); let samples: Vec<f32> = (0..4_000).map(|i| (i as f32) / 1000.0).collect();
write_wav(&path, &AudioSamples::mono_16khz(samples)).unwrap(); write_wav(&path, &AudioSamples::mono_16khz(samples)).unwrap();
@@ -247,7 +251,7 @@ mod tests {
#[test] #[test]
fn missing_file_surfaces_error() { 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); let result = decode_audio_file(&path);
assert!(result.is_err(), "missing file must error, got: {result:?}"); assert!(result.is_err(), "missing file must error, got: {result:?}");
} }

View File

@@ -2,9 +2,9 @@ use rubato::{
Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction, Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction,
}; };
use magnotia_core::constants::WHISPER_SAMPLE_RATE; use lumotia_core::constants::WHISPER_SAMPLE_RATE;
use magnotia_core::error::{MagnotiaError, Result}; use lumotia_core::error::{Error, Result};
use magnotia_core::types::AudioSamples; use lumotia_core::types::AudioSamples;
/// Resample audio to 16kHz mono using sinc interpolation (rubato). /// Resample audio to 16kHz mono using sinc interpolation (rubato).
/// Returns a new AudioSamples at the target sample rate. /// Returns a new AudioSamples at the target sample rate.
@@ -17,7 +17,7 @@ pub fn resample_to_16khz(audio: &AudioSamples) -> Result<AudioSamples> {
} }
if from_rate == 0 { if from_rate == 0 {
return Err(MagnotiaError::AudioDecodeFailed( return Err(Error::AudioDecodeFailed(
"Cannot resample: source rate is 0".into(), "Cannot resample: source rate is 0".into(),
)); ));
} }
@@ -36,7 +36,7 @@ pub fn resample_to_16khz(audio: &AudioSamples) -> Result<AudioSamples> {
let mut resampler = SincFixedIn::<f32>::new( let mut resampler = SincFixedIn::<f32>::new(
ratio, 1.1, params, chunk_size, 1, // mono 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 samples = audio.samples();
let mut output_samples: Vec<f32> = Vec::new(); let mut output_samples: Vec<f32> = Vec::new();
@@ -53,7 +53,7 @@ pub fn resample_to_16khz(audio: &AudioSamples) -> Result<AudioSamples> {
let input = vec![chunk]; let input = vec![chunk];
let result = resampler let result = resampler
.process(&input, None) .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() { if !result.is_empty() && !result[0].is_empty() {
output_samples.extend_from_slice(&result[0]); output_samples.extend_from_slice(&result[0]);

View File

@@ -27,8 +27,8 @@ use rubato::{
Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction, Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction,
}; };
use magnotia_core::constants::WHISPER_SAMPLE_RATE; use lumotia_core::constants::WHISPER_SAMPLE_RATE;
use magnotia_core::error::{MagnotiaError, Result}; use lumotia_core::error::{Error, Result};
/// Number of input samples the rubato resampler consumes per `process()` /// Number of input samples the rubato resampler consumes per `process()`
/// call. Matches the chunk size used in `resample::resample_to_16khz`. /// call. Matches the chunk size used in `resample::resample_to_16khz`.
@@ -51,7 +51,7 @@ impl StreamingResampler {
/// rubato rejects the requested ratio. /// rubato rejects the requested ratio.
pub fn new(from_rate: u32) -> Result<Self> { pub fn new(from_rate: u32) -> Result<Self> {
if from_rate == 0 { if from_rate == 0 {
return Err(MagnotiaError::AudioDecodeFailed( return Err(Error::AudioDecodeFailed(
"StreamingResampler: input sample rate is 0".into(), "StreamingResampler: input sample rate is 0".into(),
)); ));
} }
@@ -77,7 +77,9 @@ impl StreamingResampler {
INPUT_CHUNK, INPUT_CHUNK,
1, // mono 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 { Ok(Self::Sinc {
resampler, resampler,
@@ -108,7 +110,7 @@ impl StreamingResampler {
let chunk: Vec<f32> = residual.drain(..INPUT_CHUNK).collect(); let chunk: Vec<f32> = residual.drain(..INPUT_CHUNK).collect();
let input = vec![chunk]; let input = vec![chunk];
let result = resampler.process(&input, None).map_err(|e| { let result = resampler.process(&input, None).map_err(|e| {
MagnotiaError::AudioDecodeFailed(format!( Error::AudioDecodeFailed(format!(
"StreamingResampler process failed: {e}" "StreamingResampler process failed: {e}"
)) ))
})?; })?;
@@ -142,7 +144,9 @@ impl StreamingResampler {
let input = vec![chunk]; let input = vec![chunk];
let result = resampler.process(&input, None).map_err(|e| { 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 { let Some(mut out) = result.into_iter().next() else {
@@ -166,6 +170,25 @@ impl StreamingResampler {
mod tests { mod tests {
use super::*; use super::*;
fn resampled_sine_rms(from_rate: u32, input_frequency: f32) -> f64 {
let sample_count = from_rate as usize;
let samples: Vec<f32> = (0..sample_count)
.map(|i| {
let t = i as f32 / from_rate as f32;
(std::f32::consts::TAU * input_frequency * t).sin()
})
.collect();
let mut resampler = StreamingResampler::new(from_rate).unwrap();
let mut produced = Vec::new();
for chunk in samples.chunks(997) {
produced.extend(resampler.push_samples(chunk).unwrap());
}
produced.extend(resampler.flush().unwrap());
(produced.iter().map(|&s| (s as f64).powi(2)).sum::<f64>() / produced.len() as f64).sqrt()
}
#[test] #[test]
fn passthrough_at_16khz() { fn passthrough_at_16khz() {
let mut r = StreamingResampler::new(16_000).unwrap(); let mut r = StreamingResampler::new(16_000).unwrap();
@@ -179,6 +202,24 @@ mod tests {
assert!(StreamingResampler::new(0).is_err()); assert!(StreamingResampler::new(0).is_err());
} }
#[test]
fn high_frequency_content_is_filtered_before_downsampling() {
let rms = resampled_sine_rms(48_000, 12_000.0);
assert!(
rms < 0.01,
"12kHz content must be low-pass filtered before 16kHz output with at least ~40dB attenuation; rms={rms}"
);
}
#[test]
fn near_nyquist_content_is_attenuated_before_downsampling() {
let rms = resampled_sine_rms(48_000, 9_000.0);
assert!(
rms < 0.05,
"9kHz content just above 16kHz Nyquist should be materially attenuated; rms={rms}"
);
}
#[test] #[test]
fn streaming_48k_to_16k_preserves_duration() { fn streaming_48k_to_16k_preserves_duration() {
let from_rate = 48_000u32; let from_rate = 48_000u32;

View File

@@ -7,7 +7,7 @@
// For now, all audio is treated as speech. This matches v0.2 behaviour // For now, all audio is treated as speech. This matches v0.2 behaviour
// (no VAD) and doesn't affect core functionality. // (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. /// Stub speech detector. Treats all audio as speech.
#[derive(Default)] #[derive(Default)]

View File

@@ -1,8 +1,8 @@
use std::io::BufWriter; use std::io::BufWriter;
use std::path::Path; use std::path::Path;
use magnotia_core::error::{MagnotiaError, Result}; use lumotia_core::error::{Error, Result};
use magnotia_core::types::AudioSamples; use lumotia_core::types::AudioSamples;
/// Append-friendly WAV writer for long-running captures. /// Append-friendly WAV writer for long-running captures.
/// ///
@@ -40,10 +40,11 @@ impl WavWriter {
bits_per_sample: 16, bits_per_sample: 16,
sample_format: hound::SampleFormat::Int, sample_format: hound::SampleFormat::Int,
}; };
let file = std::fs::File::create(path).map_err(MagnotiaError::Io)?; let file = std::fs::File::create(path).map_err(Error::from)?;
let buffered = BufWriter::new(file); let buffered = BufWriter::new(file);
let inner = hound::WavWriter::new(buffered, spec) let inner = hound::WavWriter::new(buffered, spec).map_err(|e| {
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?; Error::from(std::io::Error::other(format!("WAV create failed: {e}")))
})?;
Ok(Self { Ok(Self {
inner, inner,
samples_since_flush: 0, samples_since_flush: 0,
@@ -60,7 +61,7 @@ impl WavWriter {
let clamped = sample.clamp(-1.0, 1.0); let clamped = sample.clamp(-1.0, 1.0);
let int_sample = (clamped * i16::MAX as f32) as i16; let int_sample = (clamped * i16::MAX as f32) as i16;
self.inner.write_sample(int_sample).map_err(|e| { self.inner.write_sample(int_sample).map_err(|e| {
MagnotiaError::Io(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(); self.samples_since_flush += samples.len();
@@ -76,9 +77,9 @@ impl WavWriter {
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural /// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
/// boundaries (end-of-utterance, UI events) for tighter recovery. /// boundaries (end-of-utterance, UI events) for tighter recovery.
pub fn flush(&mut self) -> Result<()> { pub fn flush(&mut self) -> Result<()> {
self.inner self.inner.flush().map_err(|e| {
.flush() Error::from(std::io::Error::other(format!("WAV flush failed: {e}")))
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV flush failed: {e}"))))?; })?;
self.samples_since_flush = 0; self.samples_since_flush = 0;
Ok(()) Ok(())
} }
@@ -89,7 +90,7 @@ impl WavWriter {
/// that care about the unflushed tail should always finalise. /// that care about the unflushed tail should always finalise.
pub fn finalize(self) -> Result<()> { pub fn finalize(self) -> Result<()> {
self.inner.finalize().map_err(|e| { self.inner.finalize().map_err(|e| {
MagnotiaError::Io(std::io::Error::other(format!("WAV finalize failed: {e}"))) Error::from(std::io::Error::other(format!("WAV finalize failed: {e}")))
})?; })?;
Ok(()) Ok(())
} }
@@ -104,34 +105,35 @@ pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> {
sample_format: hound::SampleFormat::Int, sample_format: hound::SampleFormat::Int,
}; };
let mut writer = hound::WavWriter::create(path, spec) let mut writer = hound::WavWriter::create(path, spec).map_err(|e| {
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?; Error::from(std::io::Error::other(format!("WAV create failed: {e}")))
})?;
for &sample in audio.samples() { for &sample in audio.samples() {
let clamped = sample.clamp(-1.0, 1.0); let clamped = sample.clamp(-1.0, 1.0);
let int_sample = (clamped * i16::MAX as f32) as i16; let int_sample = (clamped * i16::MAX as f32) as i16;
writer writer.write_sample(int_sample).map_err(|e| {
.write_sample(int_sample) Error::from(std::io::Error::other(format!("WAV write failed: {e}")))
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV write failed: {e}"))))?; })?;
} }
writer writer.finalize().map_err(|e| {
.finalize() Error::from(std::io::Error::other(format!("WAV finalize failed: {e}")))
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV finalize failed: {e}"))))?; })?;
Ok(()) Ok(())
} }
/// Read a WAV file to f32 PCM `AudioSamples`. /// 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 /// rather than silently dropped. A previous implementation used
/// `filter_map(|s| s.ok())`, so a truncated or corrupt payload returned /// `filter_map(|s| s.ok())`, so a truncated or corrupt payload returned
/// a short, silently-partial `AudioSamples` — callers got `Ok` while /// a short, silently-partial `AudioSamples` — callers got `Ok` while
/// losing audio (flagged by the 2026-04-22 review). /// losing audio (flagged by the 2026-04-22 review).
pub fn read_wav(path: &Path) -> Result<AudioSamples> { pub fn read_wav(path: &Path) -> Result<AudioSamples> {
let reader = hound::WavReader::open(path) 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 spec = reader.spec();
let sample_rate = spec.sample_rate; let sample_rate = spec.sample_rate;
@@ -145,7 +147,7 @@ pub fn read_wav(path: &Path) -> Result<AudioSamples> {
sample sample
.map(|s| s as f32 / (1 << (bits_per_sample - 1)) as f32) .map(|s| s as f32 / (1 << (bits_per_sample - 1)) as f32)
.map_err(|e| { .map_err(|e| {
MagnotiaError::AudioDecodeFailed(format!("WAV sample decode failed: {e}")) Error::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))
}) })
}) })
.collect::<Result<Vec<f32>>>()?, .collect::<Result<Vec<f32>>>()?,
@@ -153,7 +155,7 @@ pub fn read_wav(path: &Path) -> Result<AudioSamples> {
.into_samples::<f32>() .into_samples::<f32>()
.map(|sample| { .map(|sample| {
sample.map_err(|e| { sample.map_err(|e| {
MagnotiaError::AudioDecodeFailed(format!("WAV sample decode failed: {e}")) Error::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))
}) })
}) })
.collect::<Result<Vec<f32>>>()?, .collect::<Result<Vec<f32>>>()?,
@@ -170,7 +172,7 @@ mod tests {
fn wav_writer_survives_crash() { fn wav_writer_survives_crash() {
// Property under test: a `WavWriter` that has been flushed but // Property under test: a `WavWriter` that has been flushed but
// never finalised leaves a valid, readable WAV on disk. This // 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 // mid-session, the on-disk file up to the last flush is
// recoverable. // recoverable.
// //
@@ -180,7 +182,7 @@ mod tests {
// mirrors what happens when the OS reaps the process without // mirrors what happens when the OS reaps the process without
// giving Rust a chance to run destructors. // giving Rust a chance to run destructors.
let temp_dir = std::env::temp_dir(); 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 _ = std::fs::remove_file(&path);
let mut writer = WavWriter::create(&path, 16_000, 1).unwrap(); let mut writer = WavWriter::create(&path, 16_000, 1).unwrap();
@@ -217,7 +219,7 @@ mod tests {
#[test] #[test]
fn wav_writer_append_then_finalize_roundtrips() { fn wav_writer_append_then_finalize_roundtrips() {
let temp_dir = std::env::temp_dir(); 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 _ = std::fs::remove_file(&path);
let mut writer = WavWriter::create(&path, 16_000, 1).unwrap(); let mut writer = WavWriter::create(&path, 16_000, 1).unwrap();
@@ -239,7 +241,7 @@ mod tests {
// truncated WAV returned Ok with a short samples vec. The // truncated WAV returned Ok with a short samples vec. The
// new code must propagate the error. // new code must propagate the error.
let temp_dir = std::env::temp_dir(); 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); let _ = std::fs::remove_file(&path);
// Write 100 samples (200 bytes at 16-bit). // Write 100 samples (200 bytes at 16-bit).
@@ -265,7 +267,7 @@ mod tests {
#[test] #[test]
fn wav_roundtrip() { fn wav_roundtrip() {
let temp_dir = std::env::temp_dir(); 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]); let original = AudioSamples::mono_16khz(vec![0.0, 0.5, -0.5, 0.25, -0.25]);
write_wav(&path, &original).unwrap(); write_wav(&path, &original).unwrap();

View File

@@ -1,8 +1,16 @@
[package] [package]
name = "magnotia-cloud-providers" name = "lumotia-cloud-providers"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "BYOK cloud STT provider stubs and API key storage for Magnotia" description = "Provider trait and BYOK cloud STT scaffolding for Lumotia (Lumotia)"
[dependencies] [dependencies]
magnotia-core = { path = "../core" } lumotia-core = { path = "../core" }
# Async-native trait. async_trait converts async fn into Pin<Box<dyn
# Future>> at the trait surface so TranscriptionProvider stays
# object-safe (Arc<dyn TranscriptionProvider>).
async-trait = "0.1"
# Trait types serialise across the Tauri boundary.
serde = { version = "1", features = ["derive"] }

View File

@@ -1,13 +1,13 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Mutex, OnceLock}; 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 /// 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 /// exit. This avoids the undefined behaviour of mutating process environment
/// variables from arbitrary threads while keeping the public API safe. /// variables from arbitrary threads while keeping the public API safe.
/// ///
/// `retrieve_api_key` still falls back to `MAGNOTIA_API_KEY_<PROVIDER>` environment /// `retrieve_api_key` still falls back to `LUMOTIA_API_KEY_<PROVIDER>` environment
/// variables so externally injected secrets continue to work. /// variables so externally injected secrets continue to work.
/// ///
/// TODO: Replace with the `keyring` crate (or platform-native credential /// 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()); .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 /// Returns a previously stored in-memory key when present, otherwise falls
/// back to the read-only `MAGNOTIA_API_KEY_<PROVIDER>` environment variable so /// back to the read-only `LUMOTIA_API_KEY_<PROVIDER>` environment variable so
/// operator-supplied secrets still work. /// operator-supplied secrets still work.
pub fn retrieve_api_key(provider: &str) -> Option<String> { pub fn retrieve_api_key(provider: &str) -> Option<String> {
let env_key = provider_env_key(provider); let env_key = provider_env_key(provider);
@@ -40,7 +40,7 @@ fn api_key_store() -> &'static Mutex<HashMap<String, String>> {
} }
fn provider_env_key(provider: &str) -> String { fn provider_env_key(provider: &str) -> String {
format!("MAGNOTIA_API_KEY_{}", provider.to_uppercase()) format!("LUMOTIA_API_KEY_{}", provider.to_uppercase())
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,3 +1,8 @@
pub mod keystore; pub mod keystore;
pub mod provider;
pub use keystore::{retrieve_api_key, store_api_key}; pub use keystore::{retrieve_api_key, store_api_key};
pub use provider::{
CostClass, EngineProfile, NetworkRequirement, ProviderCapabilities, ProviderId, ProviderKind,
ProviderTranscript, TranscriptionProvider,
};

View File

@@ -0,0 +1,184 @@
//! `TranscriptionProvider` is the async-native trait every transcription
//! backend implements, regardless of whether the work happens locally
//! (Whisper, Parakeet, Moonshine via the `LocalProviderAdapter` in
//! `lumotia-transcription`) or remotely (OpenAI Whisper, Groq,
//! Deepgram, etc.).
//!
//! 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 Lumotia's transcription
//! internals. The trait crate stays small; provider implementations
//! sit alongside.
//!
//! Object-safety discipline: no generic methods, no `Self`-returning
//! methods. The compile-time witness in `tests` enforces this.
use std::fmt;
use async_trait::async_trait;
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
/// profiles, settings storage, and logs. Examples: `local-whisper`,
/// `local-parakeet`, `openai-whisper`, `groq-whisper-v3`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ProviderId(String);
impl ProviderId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ProviderId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// Whether a provider runs locally or over the network. The orchestrator
/// inspects this to decide whether to honour an offline-only profile.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProviderKind {
Local,
Cloud(NetworkRequirement),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NetworkRequirement {
/// Provider requires a live network connection on every call.
Online,
/// Provider can fall back to a cached or queued path when offline.
OnlineWithFallback,
}
/// Indicative cost class for UI surfacing. Not a billing source of truth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CostClass {
/// No marginal cost beyond the user's local hardware.
Free,
/// Per-call cost; user supplies their own API key (BYOK).
PaidByok,
/// Per-call cost; provider billed by CORBEL on the user's behalf.
PaidManaged,
}
/// Capabilities a provider advertises to the orchestrator and the UI.
/// 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 {
pub sample_rate: u32,
pub channels: u16,
pub initial_prompt_supported: bool,
pub language_hint_supported: bool,
pub streaming_supported: bool,
pub cost_class: CostClass,
}
/// User-selectable engine configuration. The orchestrator resolves
/// `engine_id` against the `EngineRegistry`, then derives
/// `TranscriptionOptions` from the remaining fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngineProfile {
pub engine_id: ProviderId,
pub model_id: Option<ModelId>,
pub language: Option<String>,
pub initial_prompt: Option<String>,
}
impl EngineProfile {
pub fn new(engine_id: ProviderId) -> Self {
Self {
engine_id,
model_id: None,
language: None,
initial_prompt: None,
}
}
pub fn to_options(&self) -> TranscriptionOptions {
TranscriptionOptions {
language: self.language.clone(),
initial_prompt: self.initial_prompt.clone(),
}
}
}
/// Result returned by `TranscriptionProvider::transcribe`. Carries the
/// transcript plus inference timing so the orchestrator can surface
/// latency without losing it across the trait boundary.
#[derive(Debug, Clone)]
pub struct ProviderTranscript {
pub transcript: Transcript,
pub inference_ms: u64,
}
/// Async-native unified interface for transcription providers.
///
/// `Send + Sync` supertraits: providers live in an `Arc<dyn
/// TranscriptionProvider>` shared across tokio tasks. `async_trait`
/// macro converts the async methods into boxed futures so the trait
/// stays object-safe.
#[async_trait]
pub trait TranscriptionProvider: Send + Sync {
fn provider_id(&self) -> ProviderId;
fn kind(&self) -> ProviderKind;
fn capabilities(&self) -> ProviderCapabilities;
/// Idempotent pre-flight. Local providers verify the model is
/// loaded into memory; cloud providers validate credentials and
/// reach the endpoint. Called before the first `transcribe` of a
/// session, and again after a profile or settings change that
/// invalidates the previous prepare.
async fn prepare(&self, profile: &EngineProfile) -> Result<()>;
/// Transcribe a chunk of audio. The orchestrator passes raw audio
/// already resampled to the provider's `capabilities().sample_rate`.
async fn transcribe(
&self,
audio: AudioSamples,
options: TranscriptionOptions,
) -> Result<ProviderTranscript>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_trait_is_object_safe() {
// Compile-time witness: if the trait stops being object-safe
// (generic method, Self-returning method, missing async_trait
// attribute) this declaration fails to build. No runtime work.
let _: Option<std::sync::Arc<dyn TranscriptionProvider>> = None;
}
#[test]
fn provider_id_round_trips_display_and_str() {
let id = ProviderId::new("local-whisper");
assert_eq!(id.as_str(), "local-whisper");
assert_eq!(id.to_string(), "local-whisper");
}
#[test]
fn engine_profile_derives_options() {
let profile = EngineProfile {
engine_id: ProviderId::new("local-whisper"),
model_id: None,
language: Some("en".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("Lumotia".to_string()));
}
}

View File

@@ -1,8 +1,8 @@
[package] [package]
name = "magnotia-core" name = "lumotia-core"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Core types, constants, traits, hardware detection, and model registry for Magnotia" description = "Core types, constants, traits, hardware detection, and model registry for Lumotia"
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@@ -3,17 +3,17 @@
//! gpu_offloaded) tuples. //! gpu_offloaded) tuples.
//! //!
//! Run with: //! 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 //! Output is to stderr (tracing's default). Each unique tuple emits
//! exactly one INFO line; subsequent calls with the same tuple are //! exactly one INFO line; subsequent calls with the same tuple are
//! silenced by the per-process log-once cache. //! 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() { fn main() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter("magnotia_core=info") .with_env_filter("lumotia_core=info")
.with_target(true) .with_target(true)
.with_writer(std::io::stderr) .with_writer(std::io::stderr)
.init(); .init();
@@ -28,8 +28,8 @@ fn main() {
("No override (real sysfs probe)", None), ("No override (real sysfs probe)", None),
] { ] {
match override_value { match override_value {
Some(v) => std::env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", v), Some(v) => std::env::set_var("LUMOTIA_POWER_STATE_OVERRIDE", v),
None => std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE"), None => std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE"),
} }
// Cache invalidation so the live probe re-runs each section. // Cache invalidation so the live probe re-runs each section.
// Override paths bypass the cache anyway; this is for the // Override paths bypass the cache anyway; this is for the

View File

@@ -4,12 +4,12 @@ use serde::Serialize;
use crate::types::ModelId; 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 /// Implements `Serialize` so errors can be sent to the frontend as
/// structured JSON rather than opaque strings. /// structured JSON rather than opaque strings.
#[derive(Debug, thiserror::Error, Serialize)] #[derive(Debug, thiserror::Error, Serialize)]
pub enum MagnotiaError { pub enum Error {
#[error("model not found: {0}")] #[error("model not found: {0}")]
ModelNotFound(ModelId), ModelNotFound(ModelId),
@@ -31,30 +31,53 @@ pub enum MagnotiaError {
#[error("model download failed: {0}")] #[error("model download failed: {0}")]
DownloadFailed(String), DownloadFailed(String),
#[error("file not found: {}", .0.display())] #[error("file not found: '{}'", .0.display())]
FileNotFound(PathBuf), FileNotFound(PathBuf),
#[error("storage error: {0}")] /// Structured storage failure flowed up from `lumotia_storage::Error` via
StorageError(String), /// its `From` impl. Display reads through to `detail` so the operation +
/// source context produced by the storage crate isn't double-prefixed.
#[error("{detail}")]
Storage {
kind: StorageKind,
operation: String,
detail: String,
},
#[error("io error: {0}")] #[error("provider not registered: {0}")]
Io( ProviderNotRegistered(String),
#[from]
#[serde(serialize_with = "serialize_io_error")]
std::io::Error,
),
#[error("{0}")] #[error("io error ({kind}): {message}")]
Other(String), Io {
kind: String,
message: String,
raw_os_error: Option<i32>,
},
} }
/// Serialises `std::io::Error` as its display string, since it does /// Coarse discriminator for `Error::Storage`. The storage crate maps
/// not implement `Serialize` natively. /// its full typed error onto one of these kinds at the boundary. Backend code
fn serialize_io_error<S: serde::Serializer>( /// that wants finer-grained branching pattern-matches on
err: &std::io::Error, /// `lumotia_storage::Error` directly.
s: S, #[derive(Debug, Clone, Copy, Serialize)]
) -> std::result::Result<S::Ok, S::Error> { #[serde(rename_all = "snake_case")]
s.serialize_str(&err.to_string()) pub enum StorageKind {
DatabaseOpen,
Migration,
Query,
NotFound,
InvalidReference,
Filesystem,
} }
pub type Result<T> = std::result::Result<T, MagnotiaError>; impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Self::Io {
kind: format!("{:?}", err.kind()),
message: err.to_string(),
raw_os_error: err.raw_os_error(),
}
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -19,7 +19,7 @@ pub struct CpuInfo {
} }
/// Runtime-detected CPU feature flags relevant to the speech-to-text /// 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 /// kernels degrade roughly two tiers without AVX2, which is why we
/// surface it separately: when AVX2 is absent, the UI should warn the /// surface it separately: when AVX2 is absent, the UI should warn the
/// user that performance will be a fraction of what they would see /// user that performance will be a fraction of what they would see

View File

@@ -9,7 +9,7 @@ pub mod recommendation;
pub mod tuning; pub mod tuning;
pub mod types; pub mod types;
pub use error::{MagnotiaError, Result}; pub use error::{Error, Result};
pub use types::{ pub use types::{
AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment, Transcript, AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment, Transcript,
TranscriptionOptions, TranscriptionOptions,

View File

@@ -1,4 +1,4 @@
use std::path::PathBuf; use std::path::{Path, PathBuf};
use crate::types::ModelId; use crate::types::ModelId;
@@ -19,7 +19,7 @@ impl AppPaths {
} }
pub fn database_path(&self) -> PathBuf { 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 { pub fn recordings_dir(&self) -> PathBuf {
@@ -67,7 +67,7 @@ fn resolve_app_data_dir() -> PathBuf {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()); 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")] #[cfg(target_os = "macos")]
@@ -76,50 +76,483 @@ fn resolve_app_data_dir() -> PathBuf {
return PathBuf::from(home) return PathBuf::from(home)
.join("Library") .join("Library")
.join("Application Support") .join("Application Support")
.join("Magnotia"); .join("Lumotia");
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let legacy = PathBuf::from(&home).join(".magnotia"); let legacy_dot = PathBuf::from(&home).join(".lumotia");
if legacy.exists() { if legacy_dot.exists() {
return legacy; return legacy_dot;
} }
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() { 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") PathBuf::from(home)
.join(".local")
.join("share")
.join("lumotia")
} }
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{ {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); 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 the legacy magnotia data dir paths on the current platform.
/// Returns the matched legacy path AND its convention-preserving lumotia
/// target 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).
fn legacy_and_target_paths() -> Option<(PathBuf, PathBuf)> {
#[cfg(target_os = "windows")]
{
let local_app_data = std::env::var("LOCALAPPDATA").ok()?;
let legacy = PathBuf::from(&local_app_data).join("magnotia");
let target = PathBuf::from(local_app_data).join("lumotia");
return legacy.exists().then_some((legacy, target));
}
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").ok()?;
let app_support = PathBuf::from(home).join("Library").join("Application Support");
let legacy = app_support.join("Magnotia");
let target = app_support.join("Lumotia");
return legacy.exists().then_some((legacy, target));
}
#[cfg(target_os = "linux")]
{
let home = std::env::var("HOME").ok()?;
let dot_legacy = PathBuf::from(&home).join(".magnotia");
if dot_legacy.exists() {
return Some((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() {
return Some((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");
return Some((xdg_default_legacy, xdg_default_target));
}
None
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
let home = std::env::var("HOME").ok()?;
let legacy = PathBuf::from(&home).join(".magnotia");
let target = PathBuf::from(home).join(".lumotia");
legacy.exists().then_some((legacy, target))
}
}
/// Migrate a legacy magnotia data directory to its convention-preserving
/// lumotia equivalent on first launch. Idempotent: safe to call on every
/// boot.
///
/// Rules:
/// * If the resolved target already exists, do nothing and return
/// `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 parent dir / same convention) and rename
/// `magnotia.db` -> `lumotia.db` inside it if found.
/// * If neither exists, return `NoLegacyFound` — the first launch on a
/// clean system will create the new path itself.
///
/// Callers should treat `Err` as a hard startup failure; silently
/// continuing past a migration error orphans user data behind a fresh
/// empty lumotia dir.
pub fn migrate_legacy_data_dir() -> Result<MigrationStatus, std::io::Error> {
migrate_legacy_data_dir_inner(legacy_and_target_paths())
}
/// Test-friendly inner shape: takes the (legacy, target) pair explicitly
/// so tests don't depend on platform-specific HOME / LOCALAPPDATA / XDG
/// env vars.
fn migrate_legacy_data_dir_inner(
pair: Option<(PathBuf, PathBuf)>,
) -> Result<MigrationStatus, std::io::Error> {
let Some((from, to)) = pair else {
return Ok(MigrationStatus::NoLegacyFound);
};
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
}
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());
let metadata = entry.metadata()?;
if metadata.is_dir() {
copy_dir_recursive(&entry_path, &target_path)?;
} else if metadata.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.
#[cfg(unix)]
{
let target = std::fs::read_link(&entry_path)?;
std::os::unix::fs::symlink(target, &target_path)?;
}
#[cfg(windows)]
{
let target = std::fs::read_link(&entry_path)?;
if metadata.is_dir() {
std::os::windows::fs::symlink_dir(target, &target_path)?;
} else {
std::os::windows::fs::symlink_file(target, &target_path)?;
}
}
} else {
std::fs::copy(&entry_path, &target_path)?;
}
}
Ok(())
}
fn rename_db_file_if_present(dir: &Path) -> Result<bool, std::io::Error> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::AppPaths; use super::*;
use crate::types::ModelId; use crate::types::ModelId;
use std::path::PathBuf;
#[test] #[test]
fn derives_all_paths_from_one_base() { fn derives_all_paths_from_one_base() {
let paths = AppPaths { 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")); assert_eq!(
paths.database_path(),
PathBuf::from("/tmp/lumotia-test/lumotia.db")
);
assert_eq!( assert_eq!(
paths.speech_model_dir(&ModelId::new("whisper-base-en")), 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!( assert_eq!(
paths.llm_models_dir(), 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}"))
}
#[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_legacy_data_dir_inner(Some((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_legacy_data_dir_inner(Some((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_inner(None).expect("migrate ok");
assert_eq!(result, 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_legacy_data_dir_inner(Some((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_legacy_data_dir_inner(Some((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));
}
} }

View File

@@ -101,7 +101,7 @@ pub(crate) fn force_set_cache(state: PowerState) {
/// ///
/// Resolution order (highest to lowest priority): /// Resolution order (highest to lowest priority):
/// 1. In-process test override (set via `with_override` from unit tests). /// 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. /// case-insensitive). Used by `thread_sweep.rs` integration tests.
/// 3. Linux: `parse_power_state_from_dir("/sys/class/power_supply")`. /// 3. Linux: `parse_power_state_from_dir("/sys/class/power_supply")`.
/// 4. macOS / Windows / other: `Unknown`. /// 4. macOS / Windows / other: `Unknown`.
@@ -136,7 +136,7 @@ pub fn probe_power_state() -> PowerState {
} }
fn env_override() -> Option<PowerState> { fn env_override() -> Option<PowerState> {
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() { match raw.trim().to_ascii_lowercase().as_str() {
"ac" => Some(PowerState::OnAc), "ac" => Some(PowerState::OnAc),
"battery" => Some(PowerState::OnBattery), "battery" => Some(PowerState::OnBattery),
@@ -162,7 +162,9 @@ static TEST_OVERRIDE: Mutex<Option<PowerState>> = Mutex::new(None);
#[cfg(test)] #[cfg(test)]
fn test_get_override() -> Option<PowerState> { fn test_get_override() -> Option<PowerState> {
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned") *TEST_OVERRIDE
.lock()
.expect("power test override mutex poisoned")
} }
/// Drop-guard that resets `TEST_OVERRIDE` to `None` on drop (including on unwind). /// Drop-guard that resets `TEST_OVERRIDE` to `None` on drop (including on unwind).
@@ -174,7 +176,9 @@ struct OverrideGuard;
#[cfg(test)] #[cfg(test)]
impl Drop for OverrideGuard { impl Drop for OverrideGuard {
fn drop(&mut self) { fn drop(&mut self) {
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned") = None; *TEST_OVERRIDE
.lock()
.expect("power test override mutex poisoned") = None;
} }
} }
@@ -188,7 +192,9 @@ impl Drop for OverrideGuard {
pub(crate) fn with_override<R>(state: Option<PowerState>, body: impl FnOnce() -> R) -> R { pub(crate) fn with_override<R>(state: Option<PowerState>, body: impl FnOnce() -> R) -> R {
static TEST_LOCK: Mutex<()> = Mutex::new(()); static TEST_LOCK: Mutex<()> = Mutex::new(());
let _lock = TEST_LOCK.lock().expect("power TEST_LOCK poisoned"); let _lock = TEST_LOCK.lock().expect("power TEST_LOCK poisoned");
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned") = state; *TEST_OVERRIDE
.lock()
.expect("power test override mutex poisoned") = state;
let _guard = OverrideGuard; let _guard = OverrideGuard;
body() body()
} }
@@ -226,7 +232,10 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
write_supply(dir.path(), "AC", "Mains", "0"); write_supply(dir.path(), "AC", "Mains", "0");
write_supply(dir.path(), "BAT0", "Battery", "0"); write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnBattery); assert_eq!(
parse_power_state_from_dir(dir.path()),
PowerState::OnBattery
);
} }
#[test] #[test]
@@ -284,21 +293,21 @@ mod tests {
// env-var path tested in isolation under TEST_LOCK so it // env-var path tested in isolation under TEST_LOCK so it
// doesn't race with the in-process override tests. // doesn't race with the in-process override tests.
with_override(None, || { 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); assert_eq!(probe_power_state(), PowerState::OnBattery);
std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE"); std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE");
}); });
} }
#[test] #[test]
fn env_var_override_garbage_falls_through() { fn env_var_override_garbage_falls_through() {
with_override(None, || { 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. // Garbage value falls through to the platform probe.
// We can't assert the platform result so just assert it // We can't assert the platform result so just assert it
// doesn't panic. // doesn't panic.
let _ = probe_power_state(); let _ = probe_power_state();
std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE"); std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE");
}); });
} }

View File

@@ -39,8 +39,7 @@ impl ProcessLister {
/// Refresh the process table in place and return the current /// Refresh the process table in place and return the current
/// lowercased executable names. /// lowercased executable names.
pub fn snapshot(&mut self) -> Vec<String> { pub fn snapshot(&mut self) -> Vec<String> {
self.system self.system.refresh_processes(ProcessesToUpdate::All, true);
.refresh_processes(ProcessesToUpdate::All, true);
self.system self.system
.processes() .processes()
.values() .values()

View File

@@ -49,7 +49,7 @@ pub fn score_model(model: &'static ModelEntry, profile: &SystemProfile) -> Optio
} }
let headroom = Megabytes(profile.ram.0.saturating_sub(model.ram_required.0)); let headroom = Megabytes(profile.ram.0.saturating_sub(model.ram_required.0));
if headroom > Megabytes::from_gb(4.0) { if headroom > Megabytes::from_gb(4) {
score += 10.0; score += 10.0;
} }
@@ -184,7 +184,7 @@ mod tests {
fn parakeet_is_top_recommendation_when_hardware_supports_it() { fn parakeet_is_top_recommendation_when_hardware_supports_it() {
// Any machine that fits Parakeet in RAM should see it ranked first — // Any machine that fits Parakeet in RAM should see it ranked first —
// Parakeet-TDT is English-only but beats Whisper on English at lower // 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 // (Users on non-English languages adjust manually — handled at the
// settings-UI level, not at the scoring level for now.) // settings-UI level, not at the scoring level for now.)
let profile = profile_with_ram(Megabytes(16384)); let profile = profile_with_ram(Megabytes(16384));

View File

@@ -16,7 +16,7 @@ pub const MIN_INFERENCE_THREADS: usize = 2;
/// 8 is a conservative ceiling that leaves <5% on the table for /// 8 is a conservative ceiling that leaves <5% on the table for
/// big-iron desktops while keeping consumer 6c/12t laptops out of /// big-iron desktops while keeping consumer 6c/12t laptops out of
/// contention territory. Users can override at runtime via /// contention territory. Users can override at runtime via
/// MAGNOTIA_INFERENCE_THREADS. /// LUMOTIA_INFERENCE_THREADS.
pub const MAX_INFERENCE_THREADS: usize = 8; pub const MAX_INFERENCE_THREADS: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -45,13 +45,13 @@ fn log_seen() -> &'static Mutex<HashSet<(Workload, bool, bool)>> {
/// the battery and GPU-offload heuristics. /// the battery and GPU-offload heuristics.
/// ///
/// Resolution order: /// 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). /// 2. base = num_cpus::get_physical() (fallback: available_parallelism).
/// 3. on battery → base /= 2. /// 3. on battery → base /= 2.
/// 4. gpu_offloaded → base = min(base, gpu_floor(workload)). /// 4. gpu_offloaded → base = min(base, gpu_floor(workload)).
/// 5. clamp to [MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS]. /// 5. clamp to [MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS].
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize { 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::<usize>() { if let Ok(n) = s.parse::<usize>() {
if n > 0 { if n > 0 {
return n; 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 let Ok(mut seen) = log_seen().lock() {
if seen.insert(key) { if seen.insert(key) {
tracing::info!( tracing::info!(
target: "magnotia_core::tuning", target: "lumotia_core::tuning",
threads = final_value, threads = final_value,
workload = ?workload, workload = ?workload,
gpu_offloaded, gpu_offloaded,
@@ -107,13 +107,15 @@ pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize
mod tests { mod tests {
use super::*; 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. /// they don't race under cargo's parallel test runner.
/// Mirrors the pattern used by `power::with_override`. /// Mirrors the pattern used by `power::with_override`.
fn with_thread_env_lock<R>(body: impl FnOnce() -> R) -> R { fn with_thread_env_lock<R>(body: impl FnOnce() -> R) -> R {
use std::sync::Mutex; use std::sync::Mutex;
static THREAD_ENV_LOCK: Mutex<()> = Mutex::new(()); static THREAD_ENV_LOCK: Mutex<()> = Mutex::new(());
let _lock = THREAD_ENV_LOCK.lock().expect("tuning thread-env lock poisoned"); let _lock = THREAD_ENV_LOCK
.lock()
.expect("tuning thread-env lock poisoned");
body() body()
} }
@@ -124,20 +126,22 @@ mod tests {
// We can't pin physical exactly without mocking num_cpus; just // We can't pin physical exactly without mocking num_cpus; just
// assert the result is in range. // assert the result is in range.
with_thread_env_lock(|| { 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); let n = inference_thread_count(Workload::Llm, false);
assert!(n >= MIN_INFERENCE_THREADS && n <= MAX_INFERENCE_THREADS, assert!(
"got {n}, expected within [{MIN_INFERENCE_THREADS}, {MAX_INFERENCE_THREADS}]"); (MIN_INFERENCE_THREADS..=MAX_INFERENCE_THREADS).contains(&n),
"got {n}, expected within [{MIN_INFERENCE_THREADS}, {MAX_INFERENCE_THREADS}]"
);
}); });
} }
#[test] #[test]
fn env_var_bypasses_clamps() { fn env_var_bypasses_clamps() {
with_thread_env_lock(|| { 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); let n = inference_thread_count(Workload::Llm, true);
assert_eq!(n, 10); assert_eq!(n, 10);
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS"); std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
}); });
} }
@@ -149,7 +153,7 @@ mod tests {
#[test] #[test]
fn battery_halves_thread_count() { fn battery_halves_thread_count() {
with_thread_env_lock(|| { 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, // Measure on battery, then on AC — sequential, not nested,
// to avoid re-entrant deadlock on power::TEST_LOCK. // to avoid re-entrant deadlock on power::TEST_LOCK.
let on_battery = crate::power::with_override(Some(PowerState::OnBattery), || { let on_battery = crate::power::with_override(Some(PowerState::OnBattery), || {
@@ -172,11 +176,13 @@ mod tests {
#[test] #[test]
fn gpu_offload_clamps_llm_to_floor() { fn gpu_offload_clamps_llm_to_floor() {
with_thread_env_lock(|| { 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), || { crate::power::with_override(Some(PowerState::OnAc), || {
let n = inference_thread_count(Workload::Llm, true); let n = inference_thread_count(Workload::Llm, true);
assert!(n <= GPU_FLOOR_LLM.max(MIN_INFERENCE_THREADS), assert!(
"Llm with GPU offload should clamp to {GPU_FLOOR_LLM}, got {n}"); n <= GPU_FLOOR_LLM.max(MIN_INFERENCE_THREADS),
"Llm with GPU offload should clamp to {GPU_FLOOR_LLM}, got {n}"
);
assert!(n >= MIN_INFERENCE_THREADS); assert!(n >= MIN_INFERENCE_THREADS);
}); });
}); });
@@ -185,7 +191,7 @@ mod tests {
#[test] #[test]
fn gpu_offload_clamps_whisper_to_floor() { fn gpu_offload_clamps_whisper_to_floor() {
with_thread_env_lock(|| { 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), || { crate::power::with_override(Some(PowerState::OnAc), || {
let n = inference_thread_count(Workload::Whisper, true); let n = inference_thread_count(Workload::Whisper, true);
// Whisper floor is 4 on machines with >=4 physical cores; // Whisper floor is 4 on machines with >=4 physical cores;
@@ -196,17 +202,18 @@ mod tests {
}); });
} }
const _: () = assert!(GPU_FLOOR_WHISPER >= GPU_FLOOR_LLM);
#[test] #[test]
fn whisper_gpu_floor_is_at_least_llm_gpu_floor() { fn whisper_gpu_floor_is_at_least_llm_gpu_floor() {
// Architectural invariant: whisper retains CPU work even when // Architectural invariant: whisper retains CPU work even when
// GPU-offloaded; its floor must not be lower than the LLM's. // GPU-offloaded; its floor must not be lower than the LLM's.
assert!(GPU_FLOOR_WHISPER >= GPU_FLOOR_LLM);
} }
#[test] #[test]
fn gpu_offload_off_does_not_clamp_below_battery_calc() { fn gpu_offload_off_does_not_clamp_below_battery_calc() {
with_thread_env_lock(|| { 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. // Sequential measurements; with_override is non-reentrant.
crate::power::with_override(Some(PowerState::OnAc), || { crate::power::with_override(Some(PowerState::OnAc), || {
let no_gpu = inference_thread_count(Workload::Llm, false); let no_gpu = inference_thread_count(Workload::Llm, false);
@@ -223,7 +230,7 @@ mod tests {
// wired. This is covered by the other tests too, but kept // wired. This is covered by the other tests too, but kept
// explicitly to document the behaviour. // explicitly to document the behaviour.
with_thread_env_lock(|| { 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), || { crate::power::with_override(Some(PowerState::OnBattery), || {
let _ = inference_thread_count(Workload::Llm, true); let _ = inference_thread_count(Workload::Llm, true);
let _ = inference_thread_count(Workload::Whisper, false); let _ = inference_thread_count(Workload::Whisper, false);

View File

@@ -1,11 +1,18 @@
use serde::{Deserialize, Serialize}; use std::borrow::Cow;
use std::num::NonZeroU32;
use serde::{Deserialize, Deserializer, Serialize};
/// Prevents passing raw strings where model IDs are expected. /// Prevents passing raw strings where model IDs are expected.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct ModelId(String); pub struct ModelId(Cow<'static, str>);
impl ModelId { impl ModelId {
pub fn new(id: impl Into<String>) -> Self { pub const fn borrowed(id: &'static str) -> Self {
Self(Cow::Borrowed(id))
}
pub fn new(id: impl Into<Cow<'static, str>>) -> Self {
Self(id.into()) Self(id.into())
} }
@@ -14,6 +21,15 @@ impl ModelId {
} }
} }
impl<'de> Deserialize<'de> for ModelId {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer).map(|s| Self(Cow::Owned(s)))
}
}
impl std::fmt::Display for ModelId { impl std::fmt::Display for ModelId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0) f.write_str(&self.0)
@@ -21,11 +37,15 @@ impl std::fmt::Display for ModelId {
} }
/// Prevents passing raw strings where engine names are expected. /// Prevents passing raw strings where engine names are expected.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct EngineName(String); pub struct EngineName(Cow<'static, str>);
impl EngineName { impl EngineName {
pub fn new(name: impl Into<String>) -> Self { pub const fn borrowed(name: &'static str) -> Self {
Self(Cow::Borrowed(name))
}
pub fn new(name: impl Into<Cow<'static, str>>) -> Self {
Self(name.into()) Self(name.into())
} }
@@ -34,6 +54,15 @@ impl EngineName {
} }
} }
impl<'de> Deserialize<'de> for EngineName {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer).map(|s| Self(Cow::Owned(s)))
}
}
impl std::fmt::Display for EngineName { impl std::fmt::Display for EngineName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0) f.write_str(&self.0)
@@ -45,8 +74,12 @@ impl std::fmt::Display for EngineName {
pub struct Megabytes(pub u64); pub struct Megabytes(pub u64);
impl Megabytes { impl Megabytes {
pub fn from_gb(gb: f64) -> Self { pub const fn from_gb(gb: u64) -> Self {
Self((gb * 1024.0) as u64) Self(gb.saturating_mul(1024))
}
pub const fn from_mb(mb: u64) -> Self {
Self(mb)
} }
pub fn as_gb(&self) -> f64 { pub fn as_gb(&self) -> f64 {
@@ -68,23 +101,36 @@ impl std::fmt::Display for Megabytes {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AudioSamples { pub struct AudioSamples {
samples: Vec<f32>, samples: Vec<f32>,
sample_rate: u32, sample_rate: NonZeroU32,
channels: u16, channels: u16,
} }
impl AudioSamples { impl AudioSamples {
pub fn new(samples: Vec<f32>, sample_rate: u32, channels: u16) -> Self { pub fn new(samples: Vec<f32>, sample_rate: u32, channels: u16) -> Self {
Self { Self::try_new(samples, sample_rate, channels)
.expect("AudioSamples sample_rate must be non-zero")
}
pub fn try_new(
samples: Vec<f32>,
sample_rate: u32,
channels: u16,
) -> std::result::Result<Self, &'static str> {
let Some(sample_rate) = NonZeroU32::new(sample_rate) else {
return Err("sample_rate must be non-zero");
};
Ok(Self {
samples, samples,
sample_rate, sample_rate,
channels, channels,
} })
} }
pub fn mono_16khz(samples: Vec<f32>) -> Self { pub fn mono_16khz(samples: Vec<f32>) -> Self {
Self { Self {
samples, samples,
sample_rate: crate::constants::WHISPER_SAMPLE_RATE, sample_rate: NonZeroU32::new(crate::constants::WHISPER_SAMPLE_RATE)
.expect("WHISPER_SAMPLE_RATE must be non-zero"),
channels: crate::constants::WHISPER_CHANNELS, channels: crate::constants::WHISPER_CHANNELS,
} }
} }
@@ -98,7 +144,7 @@ impl AudioSamples {
} }
pub fn sample_rate(&self) -> u32 { pub fn sample_rate(&self) -> u32 {
self.sample_rate self.sample_rate.get()
} }
pub fn channels(&self) -> u16 { pub fn channels(&self) -> u16 {
@@ -106,10 +152,7 @@ impl AudioSamples {
} }
pub fn duration_secs(&self) -> f64 { pub fn duration_secs(&self) -> f64 {
if self.sample_rate == 0 { self.samples.len() as f64 / self.sample_rate.get() as f64
return 0.0;
}
self.samples.len() as f64 / self.sample_rate as f64
} }
} }

View File

@@ -1,14 +1,14 @@
[package] [package]
name = "magnotia-hotkey" name = "lumotia-hotkey"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Wayland-compatible global hotkey listener for Magnotia — evdev backend with device hotplug" description = "Wayland-compatible global hotkey listener for Lumotia — evdev backend with device hotplug"
[dependencies] [dependencies]
magnotia-core = { path = "../core" } lumotia-core = { path = "../core" }
tokio = { version = "1", features = ["rt", "sync", "macros", "time"] } tokio = { version = "1", features = ["rt", "sync", "macros", "time"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
log = "0.4" tracing = "0.1"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
evdev = { version = "0.12", features = ["tokio"] } evdev = { version = "0.12", features = ["tokio"] }

View File

@@ -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 //! 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 //! global hotkeys without any display-server dependency. This works on both X11
@@ -8,7 +8,7 @@
//! On non-Linux platforms, this crate is a no-op — the Tauri global-shortcut //! On non-Linux platforms, this crate is a no-op — the Tauri global-shortcut
//! plugin handles hotkeys there. //! 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")] #[cfg(target_os = "linux")]
mod linux; mod linux;

View File

@@ -88,19 +88,19 @@ impl EvdevHotkeyListener {
match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) { match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) {
Ok(()) => Some(w), Ok(()) => Some(w),
Err(e) => { Err(e) => {
eprintln!( tracing::warn!(
"[magnotia-hotkey] cannot watch /dev/input ({e}); \ error = %e,
hotplug detection disabled, devices present \ "cannot watch /dev/input; hotplug detection disabled, \
at startup still work", devices present at startup still work"
); );
None None
} }
} }
} }
Err(e) => { Err(e) => {
eprintln!( tracing::warn!(
"[magnotia-hotkey] cannot create inotify watcher ({e}); \ error = %e,
hotplug detection disabled", "cannot create inotify watcher; hotplug detection disabled"
); );
None None
} }
@@ -199,7 +199,7 @@ async fn scan_and_attach(
let entries = match std::fs::read_dir(input_dir) { let entries = match std::fs::read_dir(input_dir) {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
log::error!("Cannot read /dev/input: {e}"); tracing::error!(error = %e, "cannot read /dev/input");
return; return;
} }
}; };
@@ -233,7 +233,7 @@ async fn try_attach_device(
let device = match Device::open(path) { let device = match Device::open(path) {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => {
log::debug!("Cannot open {}: {e}", path.display()); tracing::debug!(path = %path.display(), error = %e, "cannot open device");
return false; return false;
} }
}; };
@@ -243,10 +243,10 @@ async fn try_attach_device(
} }
let device_name = device.name().unwrap_or("unknown").to_string(); let device_name = device.name().unwrap_or("unknown").to_string();
log::info!( tracing::info!(
"Attached hotkey listener to: {} ({})", device = %device_name,
device_name, path = %path.display(),
path.display() "attached hotkey listener"
); );
tracked_set.insert(path.to_path_buf()); tracked_set.insert(path.to_path_buf());
@@ -260,7 +260,11 @@ async fn try_attach_device(
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = device_listener(device, hotkey_rx, event_tx).await { if let Err(e) = device_listener(device, hotkey_rx, event_tx).await {
log::warn!("Device listener for {} ended: {e}", path_owned.display()); tracing::warn!(
path = %path_owned.display(),
error = %e,
"device listener ended"
);
} }
// Remove from tracked set so hotplug can re-attach if reconnected // Remove from tracked set so hotplug can re-attach if reconnected
tracked.lock().await.remove(&path_owned); tracked.lock().await.remove(&path_owned);
@@ -331,8 +335,8 @@ async fn device_listener(
// shutdown. Log once and exit so // shutdown. Log once and exit so
// the listener doesn't spin // the listener doesn't spin
// sending into a closed channel. // sending into a closed channel.
log::warn!( tracing::warn!(
"Hotkey event channel closed; \ "hotkey event channel closed; \
listener for device exiting" listener for device exiting"
); );
return Ok(()); return Ok(());

View File

@@ -19,7 +19,7 @@ pub struct EvdevHotkeyListener;
impl EvdevHotkeyListener { impl EvdevHotkeyListener {
pub fn start(_combo: HotkeyCombo, _event_tx: mpsc::Sender<HotkeyEvent>) -> Self { pub fn start(_combo: HotkeyCombo, _event_tx: mpsc::Sender<HotkeyEvent>) -> Self {
log::info!("evdev hotkey listener is a no-op on this platform"); tracing::info!("evdev hotkey listener is a no-op on this platform");
Self Self
} }

View File

@@ -1,13 +1,13 @@
[package] [package]
name = "magnotia-llm" name = "lumotia-llm"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Local LLM engine for Magnotia (Qwen3.5 / Qwen3.6 via llama-cpp-2): transcript cleanup, task extraction, micro-step decomposition" description = "Local LLM engine for Lumotia (Qwen3.5 / Qwen3.6 via llama-cpp-2): transcript cleanup, task extraction, micro-step decomposition"
[features] [features]
# Default desktop build keeps the existing openmp + vulkan acceleration. # Default desktop build keeps the existing openmp + vulkan acceleration.
# Mobile / CPU-only targets can drop one or both via: # 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 # These are independent so an Android Vulkan build can opt into vulkan
# without openmp (the NDK ships OpenMP libs but the toolchain configuration # without openmp (the NDK ships OpenMP libs but the toolchain configuration
# is fragile across NDK versions). # is fragile across NDK versions).
@@ -16,10 +16,10 @@ gpu-vulkan = ["llama-cpp-2/vulkan"]
openmp = ["llama-cpp-2/openmp"] openmp = ["llama-cpp-2/openmp"]
[dependencies] [dependencies]
magnotia-core = { path = "../core" } lumotia-core = { path = "../core" }
encoding_rs = "0.8" encoding_rs = "0.8"
futures-util = "0.3" 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"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

@@ -9,7 +9,7 @@ use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::params::LlamaModelParams; use llama_cpp_2::model::params::LlamaModelParams;
use llama_cpp_2::model::{AddBos, LlamaChatMessage, LlamaChatTemplate, LlamaModel}; use llama_cpp_2::model::{AddBos, LlamaChatMessage, LlamaChatTemplate, LlamaModel};
use llama_cpp_2::sampling::LlamaSampler; 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}; use serde::{Deserialize, Serialize};
pub mod grammars; pub mod grammars;
@@ -172,8 +172,8 @@ impl LlmEngine {
// follow-up in docs/superpowers/specs/2026-05-09-battery-gpu-aware- // follow-up in docs/superpowers/specs/2026-05-09-battery-gpu-aware-
// thread-tuning-design.md (§ Out of scope). // thread-tuning-design.md (§ Out of scope).
let gpu_offloaded = use_gpu && gpu_layers >= model.n_layer(); let gpu_offloaded = use_gpu && gpu_layers >= model.n_layer();
let thread_count = i32::try_from(inference_thread_count(Workload::Llm, gpu_offloaded)) let thread_count =
.unwrap_or(4); i32::try_from(inference_thread_count(Workload::Llm, gpu_offloaded)).unwrap_or(4);
let ctx_params = LlamaContextParams::default() let ctx_params = LlamaContextParams::default()
.with_n_ctx(Some( .with_n_ctx(Some(
NonZeroU32::new(n_ctx).expect("n_ctx must be non-zero"), NonZeroU32::new(n_ctx).expect("n_ctx must be non-zero"),
@@ -212,6 +212,10 @@ impl LlmEngine {
generated.push_str(&piece); generated.push_str(&piece);
sampler.accept(next); 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) { if let Some(stop_index) = first_stop_index(&generated, &config.stop_sequences) {
generated.truncate(stop_index); generated.truncate(stop_index);
break; break;
@@ -296,13 +300,12 @@ impl LlmEngine {
} }
/// Phase 9 content-tag extraction. Emits a single (topic, intent) /// Phase 9 content-tag extraction. Emits a single (topic, intent)
/// pair under the `CONTENT_TAGS_GRAMMAR` GBNF. Truncates to the /// pair as JSON. Truncates to the trailing 2000 chars of the
/// trailing 2000 chars of the transcript so the prompt budget /// transcript so the prompt budget stays well under any model's
/// stays well under any model's context window. Determinism is /// context window. Determinism is enforced by temperature 0.0;
/// enforced by temperature 0.0 and the closed-set intent grammar /// the parsed intent is re-validated with `is_valid_intent` and
/// rule; on the rare case the model emits a parse-able-but-out-of- /// invalid JSON bubbles as `InvalidJson` so the frontend toasts a
/// set intent, we re-validate with `is_valid_intent` and bubble /// clear error.
/// `InvalidJson` so the frontend toasts a clear error.
pub fn extract_content_tags( pub fn extract_content_tags(
&self, &self,
transcript: &str, transcript: &str,
@@ -338,12 +341,11 @@ impl LlmEngine {
max_tokens: 96, max_tokens: 96,
temperature: 0.0, temperature: 0.0,
stop_sequences: vec!["<|im_end|>".to_string(), "<|im_end_of_text|>".to_string()], 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()) let tags: prompts::ContentTags = parse_json_payload(&raw)?;
.map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))?;
if !prompts::is_valid_intent(&tags.intent) { if !prompts::is_valid_intent(&tags.intent) {
return Err(EngineError::InvalidJson(format!( return Err(EngineError::InvalidJson(format!(
"intent out of closed set: {}", "intent out of closed set: {}",
@@ -459,6 +461,62 @@ fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option<usize> {
.min() .min()
} }
fn json_envelope_complete(text: &str) -> bool {
extract_json_envelope(text) == Some(text.trim())
}
fn extract_json_envelope(text: &str) -> Option<&str> {
let start = text
.char_indices()
.find_map(|(idx, ch)| (ch == '{' || ch == '[').then_some(idx))?;
let mut chars = text[start..].char_indices();
let (_, first) = chars.next()?;
let mut stack = vec![match first {
'{' => '}',
'[' => ']',
_ => unreachable!(),
}];
let mut in_string = false;
let mut escaped = false;
while let Some((offset, ch)) = chars.next() {
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(&text[start..end]);
}
}
_ => {}
}
}
None
}
fn parse_json_payload<T: for<'de> Deserialize<'de>>(raw: &str) -> Result<T, EngineError> {
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( fn render_chat_prompt(
model: &LlamaModel, model: &LlamaModel,
messages: &[(&str, &str)], messages: &[(&str, &str)],
@@ -548,6 +606,47 @@ mod tests {
assert_eq!(index, Some(5)); 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 =
"<think>\n\n</think>\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\"]"),
);
}
#[test] #[test]
fn prompt_preflight_rejects_oversized_prompt_tokens() { fn prompt_preflight_rejects_oversized_prompt_tokens() {
let err = preflight_context_window(7_105, 1_024).unwrap_err(); let err = preflight_context_window(7_105, 1_024).unwrap_err();

View File

@@ -240,7 +240,7 @@ pub fn recommend_tier(total_ram_bytes: u64, total_vram_bytes: Option<u64>) -> Ll
} }
pub fn model_dir() -> PathBuf { 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 { pub fn model_path(id: LlmModelId) -> PathBuf {
@@ -321,7 +321,7 @@ where
.unwrap_or(0); .unwrap_or(0);
let client = reqwest::Client::builder() 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)) .connect_timeout(std::time::Duration::from_secs(30))
.build() .build()
.map_err(|e| DownloadError::Http(e.to_string()))?; .map_err(|e| DownloadError::Http(e.to_string()))?;

View File

@@ -45,9 +45,9 @@ context that are not explicit commitments. Output an empty array if there are \
no action items."; no action items.";
/// Compact representation of a human-in-the-loop feedback example used /// 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 /// 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)] #[derive(Debug, Clone)]
pub struct FeedbackExample { pub struct FeedbackExample {
/// What the AI was given as input (e.g. the parent task text, or /// What the AI was given as input (e.g. the parent task text, or

View File

@@ -1,23 +1,23 @@
//! Smoke test for Phase 9 LlmEngine::extract_content_tags. //! 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 //! smoke.rs test so neither runs in default `cargo test` runs (model
//! load is heavy). Run explicitly with: //! 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 //! --test content_tags_smoke -- --nocapture
use std::env; use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use magnotia_llm::{is_valid_intent, LlmEngine, LlmModelId}; use lumotia_llm::{is_valid_intent, LlmEngine, LlmModelId};
#[test] #[test]
fn extract_content_tags_returns_valid_pair() { 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), Ok(path) => PathBuf::from(path),
Err(_) => { Err(_) => {
eprintln!("MAGNOTIA_LLM_TEST_MODEL not set — skipping"); eprintln!("LUMOTIA_LLM_TEST_MODEL not set — skipping");
return; return;
} }
}; };

View File

@@ -6,20 +6,20 @@
//! - `context::params::LlamaContextParams` //! - `context::params::LlamaContextParams`
//! - `sampling::LlamaSampler` //! - `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::env;
use std::path::PathBuf; use std::path::PathBuf;
use magnotia_llm::LlmEngine; use lumotia_llm::LlmEngine;
use magnotia_llm::LlmModelId; use lumotia_llm::LlmModelId;
#[test] #[test]
fn llama_cpp_2_smoke_generates_and_wraps() { 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), Ok(path) => PathBuf::from(path),
Err(_) => { Err(_) => {
eprintln!("MAGNOTIA_LLM_TEST_MODEL not set — skipping"); eprintln!("LUMOTIA_LLM_TEST_MODEL not set — skipping");
return; return;
} }
}; };
@@ -32,7 +32,7 @@ fn llama_cpp_2_smoke_generates_and_wraps() {
let completion = engine let completion = engine
.generate( .generate(
"Write exactly one short greeting.", "Write exactly one short greeting.",
&magnotia_llm::GenerationConfig { &lumotia_llm::GenerationConfig {
max_tokens: 32, max_tokens: 32,
temperature: 0.0, temperature: 0.0,
stop_sequences: vec!["\n".to_string()], stop_sequences: vec!["\n".to_string()],

View File

@@ -1,18 +1,18 @@
[package] [package]
name = "magnotia-mcp" name = "lumotia-mcp"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Read-only MCP stdio server exposing Magnotia transcripts and tasks to external agents" description = "Read-only MCP stdio server exposing Lumotia transcripts and tasks to external agents"
[[bin]] [[bin]]
name = "magnotia-mcp" name = "lumotia-mcp"
path = "src/main.rs" path = "src/main.rs"
[lib] [lib]
path = "src/lib.rs" path = "src/lib.rs"
[dependencies] [dependencies]
magnotia-storage = { path = "../storage" } lumotia-storage = { path = "../storage" }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

@@ -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 //! Scope: **read-only** tools. An external agent (Claude desktop, Cline, any
//! MCP-capable client) can list / search / fetch transcripts and list tasks. //! 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: newline-delimited JSON-RPC 2.0 over stdio, per the stdio
//! transport spec. Server spec version: 2024-11-05. //! transport spec. Server spec version: 2024-11-05.
@@ -12,7 +12,7 @@ use serde_json::{json, Value};
use sqlx::SqlitePool; use sqlx::SqlitePool;
pub const PROTOCOL_VERSION: &str = "2024-11-05"; 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"); pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -95,7 +95,7 @@ fn initialize_result() -> Value {
"version": SERVER_VERSION, "version": SERVER_VERSION,
}, },
"instructions": "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.", All data stays on the user's machine.",
}) })
} }
@@ -105,7 +105,7 @@ fn tools_list_result() -> Value {
"tools": [ "tools": [
{ {
"name": "list_transcripts", "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).", Returns summaries (id, title, created_at, duration, preview).",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -135,7 +135,7 @@ fn tools_list_result() -> Value {
}, },
{ {
"name": "search_transcripts", "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": { "inputSchema": {
"type": "object", "type": "object",
"required": ["query"], "required": ["query"],
@@ -155,7 +155,7 @@ fn tools_list_result() -> Value {
}, },
{ {
"name": "list_tasks", "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": { "inputSchema": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
@@ -206,7 +206,7 @@ async fn list_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value,
}; };
let limit = args.limit.unwrap_or(20).clamp(1, 200); let limit = args.limit.unwrap_or(20).clamp(1, 200);
let rows = magnotia_storage::list_transcripts(pool, limit) let rows = lumotia_storage::list_transcripts(pool, limit)
.await .await
.map_err(|e| error(-32603, format!("DB error: {e}")))?; .map_err(|e| error(-32603, format!("DB error: {e}")))?;
@@ -239,7 +239,7 @@ async fn get_transcript_tool(pool: &SqlitePool, args: Value) -> Result<Value, Js
let args: Args = serde_json::from_value(args) let args: Args = serde_json::from_value(args)
.map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?; .map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?;
let row = magnotia_storage::get_transcript(pool, &args.id) let row = lumotia_storage::get_transcript(pool, &args.id)
.await .await
.map_err(|e| error(-32603, format!("DB error: {e}")))? .map_err(|e| error(-32603, format!("DB error: {e}")))?
.ok_or_else(|| error(-32000, format!("Transcript {} not found", args.id)))?; .ok_or_else(|| error(-32000, format!("Transcript {} not found", args.id)))?;
@@ -273,7 +273,7 @@ async fn search_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value
.map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?; .map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?;
let limit = args.limit.unwrap_or(20).clamp(1, 100); let limit = args.limit.unwrap_or(20).clamp(1, 100);
let rows = magnotia_storage::search_transcripts(pool, &args.query, limit) let rows = lumotia_storage::search_transcripts(pool, &args.query, limit)
.await .await
.map_err(|e| error(-32603, format!("DB error: {e}")))?; .map_err(|e| error(-32603, format!("DB error: {e}")))?;
@@ -296,7 +296,7 @@ async fn search_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value
} }
async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> { async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> {
let rows = magnotia_storage::list_tasks(pool) let rows = lumotia_storage::list_tasks(pool)
.await .await
.map_err(|e| error(-32603, format!("DB error: {e}")))?; .map_err(|e| error(-32603, format!("DB error: {e}")))?;
@@ -460,7 +460,7 @@ mod tests {
}); });
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
magnotia_storage::migrations::run_migrations(&pool) lumotia_storage::migrations::run_migrations(&pool)
.await .await
.unwrap(); .unwrap();
let response = handle_message(&pool, request).await.expect("has response"); let response = handle_message(&pool, request).await.expect("has response");

View File

@@ -1,22 +1,22 @@
//! Stdio entry point for magnotia-mcp. Reads newline-delimited JSON-RPC messages //! Stdio entry point for lumotia-mcp. Reads newline-delimited JSON-RPC messages
//! from stdin, dispatches via `magnotia_mcp::handle_message`, writes responses to //! 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. //! stdout. Logs land on stderr so they don't collide with the JSON-RPC stream.
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let db_path = magnotia_storage::database_path(); let db_path = lumotia_storage::database_path();
eprintln!( eprintln!(
"[magnotia-mcp] opening Magnotia database at {} (read-only)", "[lumotia-mcp] opening Lumotia database at {} (read-only)",
db_path.display() db_path.display()
); );
// Open read-only at the connection level so the MCP server cannot write // Open read-only at the connection level so the MCP server cannot write
// to the user's database, regardless of which tools the dispatcher // to the user's database, regardless of which tools the dispatcher
// exposes. Migrations are deliberately skipped — this binary never owns // exposes. Migrations are deliberately skipped — this binary never owns
// the schema; the main app is the single migration writer. // the schema; the main app is the single migration writer.
let pool = magnotia_storage::init_readonly(&db_path).await?; let pool = lumotia_storage::init_readonly(&db_path).await?;
eprintln!("[magnotia-mcp] ready, waiting for JSON-RPC on stdin"); eprintln!("[lumotia-mcp] ready, waiting for JSON-RPC on stdin");
let mut lines = BufReader::new(tokio::io::stdin()).lines(); let mut lines = BufReader::new(tokio::io::stdin()).lines();
let mut stdout = tokio::io::stdout(); let mut stdout = tokio::io::stdout();
@@ -28,7 +28,7 @@ async fn main() -> anyhow::Result<()> {
} }
let response = match serde_json::from_str::<serde_json::Value>(trimmed) { let response = match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(raw) => match magnotia_mcp::handle_message(&pool, raw).await { Ok(raw) => match lumotia_mcp::handle_message(&pool, raw).await {
Some(response) => response, Some(response) => response,
None => continue, // notification — no reply None => continue, // notification — no reply
}, },
@@ -38,8 +38,8 @@ async fn main() -> anyhow::Result<()> {
// logged and continued, dropping the response — // logged and continued, dropping the response —
// clients saw silence instead of a structured error // clients saw silence instead of a structured error
// (2026-04-22 review MAJOR). // (2026-04-22 review MAJOR).
eprintln!("[magnotia-mcp] parse error: {err}"); eprintln!("[lumotia-mcp] parse error: {err}");
magnotia_mcp::parse_error_response(&err.to_string()) lumotia_mcp::parse_error_response(&err.to_string())
} }
}; };

View File

@@ -1,11 +1,11 @@
[package] [package]
name = "magnotia-storage" name = "lumotia-storage"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "SQLite persistence, BM25 search, and file storage for Magnotia" description = "SQLite persistence, BM25 search, and file storage for Lumotia"
[dependencies] [dependencies]
magnotia-core = { path = "../core" } lumotia-core = { path = "../core" }
# SQLite with compile-time checked queries # SQLite with compile-time checked queries
# default-features = false strips sqlx's `any`, `macros`, `migrate`, `json` — # default-features = false strips sqlx's `any`, `macros`, `migrate`, `json` —
@@ -24,5 +24,8 @@ serde = { version = "1", features = ["derive"] }
# Logging # Logging
log = "0.4" log = "0.4"
# Structured error derivation for lumotia_storage::Error.
thiserror = "1"
# UUIDs for profile + profile_terms ids (v7 random). # UUIDs for profile + profile_terms ids (v7 random).
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }

File diff suppressed because it is too large Load Diff

194
crates/storage/src/error.rs Normal file
View File

@@ -0,0 +1,194 @@
//! Storage-local typed error.
//!
//! Backend code that wants to branch on storage failure modes pattern-matches
//! 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
//! touching call sites.
//!
//! The `sqlx::Error` source is deliberately not serialized. Sources stay sources;
//! only the boundary shape is wire-friendly.
use std::borrow::Cow;
use std::path::PathBuf;
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)]
pub enum OpenOp {
Connect,
ReadOnlyConnect,
ForeignKeysPragma,
}
impl OpenOp {
fn label(self) -> &'static str {
match self {
OpenOp::Connect => "connect",
OpenOp::ReadOnlyConnect => "read_only_connect",
OpenOp::ForeignKeysPragma => "foreign_keys_pragma",
}
}
}
impl std::fmt::Display for OpenOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// Per-step phase inside the migration framework.
#[derive(Debug, Clone, Copy)]
pub enum MigrationStep {
SchemaVersionTableCreate,
SchemaVersionQuery,
TxBegin,
Apply,
RecordVersion,
Commit,
}
impl MigrationStep {
fn label(self) -> &'static str {
match self {
MigrationStep::SchemaVersionTableCreate => "schema_version_table_create",
MigrationStep::SchemaVersionQuery => "schema_version_query",
MigrationStep::TxBegin => "tx_begin",
MigrationStep::Apply => "apply",
MigrationStep::RecordVersion => "record_version",
MigrationStep::Commit => "commit",
}
}
}
impl std::fmt::Display for MigrationStep {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// Entities the storage layer can fail to find or validate.
///
/// Seeded with only the three real entities (Transcript, Task, Profile) — add
/// more here only when a typed `NotFound` / `InvalidReference` case actually
/// requires them.
#[derive(Debug, Clone, Copy)]
pub enum Entity {
Transcript,
Task,
Profile,
ImplementationRule,
Feedback,
}
impl Entity {
fn label(self) -> &'static str {
match self {
Entity::Transcript => "transcript",
Entity::Task => "task",
Entity::Profile => "profile",
Entity::ImplementationRule => "implementation_rule",
Entity::Feedback => "feedback",
}
}
}
impl std::fmt::Display for Entity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// Storage-crate-local typed error.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("database open failed during {operation}: {source}")]
DatabaseOpen {
operation: OpenOp,
#[source]
source: sqlx::Error,
},
#[error("migration step {step} (version {version:?}) failed: {source}")]
Migration {
version: Option<i64>,
step: MigrationStep,
#[source]
source: sqlx::Error,
},
#[error("query failed during {operation}: {source}")]
Query {
operation: Cow<'static, str>,
#[source]
source: sqlx::Error,
},
#[error("{entity} not found: '{key}'")]
NotFound { entity: Entity, key: String },
#[error("invalid {entity} reference: {reason}")]
InvalidReference {
entity: Entity,
reason: Cow<'static, str>,
},
#[error("filesystem operation failed at '{}': {source}", path.display())]
Filesystem {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl Error {
/// Discriminator for the boundary conversion into [`Error`].
pub fn kind(&self) -> StorageKind {
match self {
Error::DatabaseOpen { .. } => StorageKind::DatabaseOpen,
Error::Migration { .. } => StorageKind::Migration,
Error::Query { .. } => StorageKind::Query,
Error::NotFound { .. } => StorageKind::NotFound,
Error::InvalidReference { .. } => StorageKind::InvalidReference,
Error::Filesystem { .. } => StorageKind::Filesystem,
}
}
/// 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> {
match self {
Error::DatabaseOpen { operation, .. } => Cow::Borrowed(operation.label()),
Error::Migration { step, .. } => Cow::Borrowed(step.label()),
Error::Query { operation, .. } => operation.clone(),
Error::NotFound { entity, .. } => Cow::Owned(format!("not_found:{}", entity.label())),
Error::InvalidReference { entity, .. } => {
Cow::Owned(format!("invalid_reference:{}", entity.label()))
}
Error::Filesystem { .. } => Cow::Borrowed("filesystem"),
}
}
}
/// Boundary conversion — flattens the typed error into the wire-friendly
/// [`CoreError::Storage`] variant. Lives in the storage crate (not in core)
/// to avoid a `core -> storage` dependency cycle.
impl From<Error> for CoreError {
fn from(error: Error) -> Self {
let kind = error.kind();
let operation = error.operation_label().into_owned();
let detail = error.to_string();
CoreError::Storage {
kind,
operation,
detail,
}
}
}
/// `Result` alias for storage-crate functions.
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -1,28 +1,28 @@
use std::path::PathBuf; use std::path::PathBuf;
pub fn app_data_dir() -> 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. /// Path to the SQLite database file.
pub fn database_path() -> PathBuf { pub fn database_path() -> PathBuf {
magnotia_core::paths::app_paths().database_path() lumotia_core::paths::app_paths().database_path()
} }
/// Directory for saved audio recordings. /// Directory for saved audio recordings.
pub fn recordings_dir() -> PathBuf { 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. /// Directory for crash dumps written by the Rust panic hook.
/// Each crash is a single text file: `<unix-ts>-<short-id>.crash`. /// Each crash is a single text file: `<unix-ts>-<short-id>.crash`.
/// Used by the diagnostic-report bundler in Settings → About. /// Used by the diagnostic-report bundler in Settings → About.
pub fn crashes_dir() -> PathBuf { 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). /// Directory for the rolling Rust log file (lumotia.log + rotated lumotia.log.1, etc).
/// Subscribers configured in src-tauri/src/lib.rs at startup. /// Subscribers configured in src-tauri/src/lib.rs at startup.
pub fn logs_dir() -> PathBuf { pub fn logs_dir() -> PathBuf {
magnotia_core::paths::app_paths().logs_dir() lumotia_core::paths::app_paths().logs_dir()
} }

View File

@@ -1,7 +1,10 @@
pub mod database; pub mod database;
pub mod error;
pub mod file_storage; pub mod file_storage;
pub mod migrations; pub mod migrations;
pub use error::{Entity, Error, MigrationStep, OpenOp, Result};
/// Stable identifier for the seeded Default profile (see migration v6). /// Stable identifier for the seeded Default profile (see migration v6).
/// The Default profile cannot be renamed or deleted — guarded by SQLite triggers. /// The Default profile cannot be renamed or deleted — guarded by SQLite triggers.
pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001"; pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
@@ -14,9 +17,9 @@ pub use database::{
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms, insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks, list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired, list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled, migrate_legacy_setting_keys, prune_error_log, record_feedback, search_transcripts,
set_setting, set_implementation_rule_enabled, set_setting, set_task_energy, uncomplete_task, update_profile,
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript, update_task, update_transcript,
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType, update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow, ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
RecordFeedbackParams, TaskRow, TranscriptRow, RecordFeedbackParams, TaskRow, TranscriptRow,

View File

@@ -1,4 +1,4 @@
use magnotia_core::error::{MagnotiaError, Result}; use crate::error::{Error, MigrationStep, Result};
use sqlx::SqlitePool; use sqlx::SqlitePool;
/// Each migration is a (version, description, sql) tuple. /// Each migration is a (version, description, sql) tuple.
@@ -553,27 +553,39 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
) )
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Schema version table creation failed: {e}")))?; .map_err(|source| Error::Migration {
version: None,
step: MigrationStep::SchemaVersionTableCreate,
source,
})?;
let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version") let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Schema version query failed: {e}")))?; .map_err(|source| Error::Migration {
version: None,
step: MigrationStep::SchemaVersionQuery,
source,
})?;
for (version, description, sql) in migrations { for (version, description, sql) in migrations {
if *version > current { if *version > current {
log::info!("Running migration {}: {}", version, description); log::info!("Running migration {}: {}", version, description);
let mut tx = pool.begin().await.map_err(|e| { let mut tx = pool.begin().await.map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration {} tx begin failed: {e}", version)) version: Some(*version),
step: MigrationStep::TxBegin,
source,
})?; })?;
for statement in split_statements(sql) { for statement in split_statements(sql) {
sqlx::query(&statement) sqlx::query(&statement)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| { .map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration {} failed: {e}", version)) version: Some(*version),
step: MigrationStep::Apply,
source,
})?; })?;
} }
@@ -582,12 +594,16 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
.bind(description) .bind(description)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| { .map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration version record failed: {e}")) version: Some(*version),
step: MigrationStep::RecordVersion,
source,
})?; })?;
tx.commit().await.map_err(|e| { tx.commit().await.map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration {} commit failed: {e}", version)) version: Some(*version),
step: MigrationStep::Commit,
source,
})?; })?;
log::info!("Migration {} complete", version); log::info!("Migration {} complete", version);
@@ -947,7 +963,7 @@ mod tests {
// dictionary.id is INTEGER PK AUTOINCREMENT (see v2); let SQLite assign rowids. // dictionary.id is INTEGER PK AUTOINCREMENT (see v2); let SQLite assign rowids.
sqlx::query( sqlx::query(
"INSERT INTO dictionary (term, note, created_at) VALUES \ "INSERT INTO dictionary (term, note, created_at) VALUES \
('Magnotia', '', datetime('now')), \ ('Lumotia', '', datetime('now')), \
('CORBEL', 'brand', datetime('now')), \ ('CORBEL', 'brand', datetime('now')), \
('Wren', '', datetime('now'))", ('Wren', '', datetime('now'))",
) )
@@ -1178,7 +1194,8 @@ mod tests {
.map(|r| r.get::<String, _>("detail")) .map(|r| r.get::<String, _>("detail"))
.collect(); .collect();
assert!( assert!(
plan.iter().any(|row| row.contains("idx_transcripts_profile_created")), plan.iter()
.any(|row| row.contains("idx_transcripts_profile_created")),
"planner should use the composite index, got plan: {plan:?}", "planner should use the composite index, got plan: {plan:?}",
); );
} }

View File

@@ -1,8 +1,8 @@
[package] [package]
name = "magnotia-transcription" name = "lumotia-transcription"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Speech-to-text engine wrappers, model management, and inference concurrency for Magnotia" description = "Speech-to-text engine wrappers, model management, and inference concurrency for Lumotia"
build = "build.rs" build = "build.rs"
[features] [features]
@@ -15,13 +15,21 @@ build = "build.rs"
# `whisper-vulkan` is a separate feature so a non-Vulkan target (Android # `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 # without GPU drivers, a CPU-only Windows build) can pull in whisper-rs
# but skip the Vulkan backend. Build CPU-only with: # 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"] default = ["whisper", "whisper-vulkan"]
whisper = ["dep:whisper-rs"] whisper = ["dep:whisper-rs"]
whisper-vulkan = ["whisper-rs?/vulkan"] whisper-vulkan = ["whisper-rs?/vulkan"]
[dependencies] [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.
lumotia-cloud-providers = { path = "../cloud-providers" }
# Async-trait for the LocalProviderAdapter impl.
async-trait = "0.1"
# Parakeet via ONNX. Whisper is handled directly via whisper-rs below. # Parakeet via ONNX. Whisper is handled directly via whisper-rs below.
transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"] } transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"] }
@@ -49,10 +57,12 @@ thiserror = "2"
tracing = "0.1" tracing = "0.1"
[dev-dependencies] [dev-dependencies]
# TcpListener fixture for the download resume tests (mirrors magnotia-llm). # TcpListener fixture for the download resume tests (mirrors lumotia-llm).
tokio = { version = "1", features = ["rt", "sync", "net", "io-util", "macros"] } # `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" tempfile = "3"
# Test-only — used by tests/thread_sweep.rs to label physical vs logical # Test-only — used by tests/thread_sweep.rs to label physical vs logical
# core counts in the scaling table. Production code uses the # 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" num_cpus = "1"

View File

@@ -11,7 +11,7 @@
//! workspace ever pulls `tokenizers` into the dependency graph on a //! workspace ever pulls `tokenizers` into the dependency graph on a
//! Windows target. If we ever legitimately need it we can reintroduce //! Windows target. If we ever legitimately need it we can reintroduce
//! it via a sidecar (isolated process, separate CRT) rather than //! 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 //! The check is advisory on non-Windows targets — it still prints a
//! cargo:warning if `tokenizers` appears, so the Windows failure isn't //! cargo:warning if `tokenizers` appears, so the Windows failure isn't
@@ -56,7 +56,7 @@ fn main() {
if target_os == "windows" { if target_os == "windows" {
panic!( 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 \ 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 \ 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 \ tokenizer usage through an out-of-process sidecar instead, or gate it off for \
@@ -65,7 +65,7 @@ fn main() {
} }
println!( 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 \ 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 \ at build time per docs/whisper-ecosystem/brief.md item #6. Isolate tokenizer usage \
in a sidecar before a Windows ship." in a sidecar before a Windows ship."

View File

@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use magnotia_core::error::{MagnotiaError, Result}; use lumotia_core::error::{Error, Result};
use magnotia_core::types::{AudioSamples, TranscriptionOptions}; use lumotia_core::types::{AudioSamples, TranscriptionOptions};
use crate::local_engine::{LocalEngine, TimedTranscript}; use crate::local_engine::{LocalEngine, TimedTranscript};
@@ -14,5 +14,5 @@ pub async fn run_inference(
) -> Result<TimedTranscript> { ) -> Result<TimedTranscript> {
tokio::task::spawn_blocking(move || engine.transcribe_sync(&audio, &options)) tokio::task::spawn_blocking(move || engine.transcribe_sync(&audio, &options))
.await .await
.map_err(|e| MagnotiaError::TranscriptionFailed(format!("Task join error: {e}")))? .map_err(|e| Error::TranscriptionFailed(format!("Task join error: {e}")))?
} }

View File

@@ -1,6 +1,8 @@
pub mod concurrency; pub mod concurrency;
pub mod local_engine; pub mod local_engine;
pub mod model_manager; pub mod model_manager;
pub mod orchestrator;
pub mod registry;
pub mod streaming; pub mod streaming;
pub mod transcriber; pub mod transcriber;
#[cfg(feature = "whisper")] #[cfg(feature = "whisper")]
@@ -11,9 +13,21 @@ pub use concurrency::run_inference;
pub use local_engine::load_whisper; pub use local_engine::load_whisper;
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript}; pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir}; pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
pub use orchestrator::{LocalProviderAdapter, Orchestrator};
pub use registry::EngineRegistry;
pub use streaming::{ pub use streaming::{
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy, sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker, LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
}; };
pub use transcribe_rs::SpeechModel; pub use transcribe_rs::SpeechModel;
pub use transcriber::{Transcriber, TranscriberCapabilities}; pub use transcriber::{Transcriber, TranscriberCapabilities};
// Re-export the trait surface so downstream crates depend only on
// `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 lumotia_cloud_providers::{
CostClass, EngineProfile, NetworkRequirement, ProviderCapabilities, ProviderId, ProviderKind,
ProviderTranscript, TranscriptionProvider,
};

View File

@@ -4,8 +4,8 @@ use std::time::Instant;
use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult}; use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult};
use magnotia_core::error::{MagnotiaError, Result}; use lumotia_core::error::{Error, Result};
use magnotia_core::types::{ use lumotia_core::types::{
AudioSamples, EngineName, ModelId, Segment, Transcript, TranscriptionOptions, AudioSamples, EngineName, ModelId, Segment, Transcript, TranscriptionOptions,
}; };
@@ -28,7 +28,7 @@ pub struct SpeechModelAdapter(pub Box<dyn SpeechModel + Send>);
impl Transcriber for SpeechModelAdapter { impl Transcriber for SpeechModelAdapter {
fn capabilities(&self) -> TranscriberCapabilities { fn capabilities(&self) -> TranscriberCapabilities {
TranscriberCapabilities { TranscriberCapabilities {
sample_rate: magnotia_core::constants::WHISPER_SAMPLE_RATE, sample_rate: lumotia_core::constants::WHISPER_SAMPLE_RATE,
channels: 1, channels: 1,
supports_initial_prompt: false, supports_initial_prompt: false,
} }
@@ -48,7 +48,7 @@ impl Transcriber for SpeechModelAdapter {
let result: TranscriptionResult = self let result: TranscriptionResult = self
.0 .0
.transcribe(samples, &opts) .transcribe(samples, &opts)
.map_err(|e| MagnotiaError::TranscriptionFailed(e.to_string()))?; .map_err(|e| Error::TranscriptionFailed(e.to_string()))?;
Ok(result Ok(result
.segments .segments
.unwrap_or_default() .unwrap_or_default()
@@ -140,7 +140,7 @@ impl LocalEngine {
options: &TranscriptionOptions, options: &TranscriptionOptions,
) -> Result<TimedTranscript> { ) -> Result<TimedTranscript> {
let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner()); 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 start = Instant::now();
let segments = backend.transcribe_sync(audio.samples(), options)?; let segments = backend.transcribe_sync(audio.samples(), options)?;
@@ -160,7 +160,7 @@ impl LocalEngine {
/// Thin wrapper over `ParakeetModel` that overrides `transcribe_raw` to /// Thin wrapper over `ParakeetModel` that overrides `transcribe_raw` to
/// request word-granularity segments. `transcribe-rs` 0.3's trait impl for /// request word-granularity segments. `transcribe-rs` 0.3's trait impl for
/// `ParakeetModel::transcribe_raw` ignores `TranscribeOptions` and uses /// `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 /// "T Est Ing . One , Two , Three" output. The concrete-type method
/// `ParakeetModel::transcribe_with` accepts `ParakeetParams` with an /// `ParakeetModel::transcribe_with` accepts `ParakeetParams` with an
/// explicit granularity; this wrapper exposes that to the trait object. /// explicit granularity; this wrapper exposes that to the trait object.
@@ -197,7 +197,7 @@ impl transcribe_rs::SpeechModel for ParakeetWordGranularity {
pub fn load_parakeet(model_dir: &Path) -> Result<Box<dyn Transcriber + Send>> { pub fn load_parakeet(model_dir: &Path) -> Result<Box<dyn Transcriber + Send>> {
use transcribe_rs::onnx::Quantization; use transcribe_rs::onnx::Quantization;
let model = transcribe_rs::onnx::parakeet::ParakeetModel::load(model_dir, &Quantization::Int8) 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( Ok(Box::new(SpeechModelAdapter(Box::new(
ParakeetWordGranularity(model), ParakeetWordGranularity(model),
)))) ))))
@@ -207,7 +207,7 @@ pub fn load_parakeet(model_dir: &Path) -> Result<Box<dyn Transcriber + Send>> {
#[cfg(feature = "whisper")] #[cfg(feature = "whisper")]
pub fn load_whisper(model_path: &Path) -> Result<Box<dyn Transcriber + Send>> { pub fn load_whisper(model_path: &Path) -> Result<Box<dyn Transcriber + Send>> {
let backend = WhisperRsBackend::load(model_path) 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)) Ok(Box::new(backend))
} }

View File

@@ -2,9 +2,9 @@ use std::collections::HashSet;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex}; use std::sync::{LazyLock, Mutex};
use magnotia_core::error::{MagnotiaError, Result}; use lumotia_core::error::{Error, Result};
use magnotia_core::model_registry::{find_model, ModelFile}; use lumotia_core::model_registry::{find_model, ModelFile};
use magnotia_core::types::{DownloadProgress, ModelId}; use lumotia_core::types::{DownloadProgress, ModelId};
static ACTIVE_DOWNLOADS: LazyLock<Mutex<HashSet<String>>> = static ACTIVE_DOWNLOADS: LazyLock<Mutex<HashSet<String>>> =
LazyLock::new(|| Mutex::new(HashSet::new())); LazyLock::new(|| Mutex::new(HashSet::new()));
@@ -18,9 +18,9 @@ impl DownloadReservation {
let id = id.as_str().to_string(); let id = id.as_str().to_string();
let mut active = ACTIVE_DOWNLOADS let mut active = ACTIVE_DOWNLOADS
.lock() .lock()
.map_err(|_| MagnotiaError::DownloadFailed("download lock poisoned".into()))?; .map_err(|_| Error::DownloadFailed("download lock poisoned".into()))?;
if !active.insert(id.clone()) { if !active.insert(id.clone()) {
return Err(MagnotiaError::DownloadFailed(format!( return Err(Error::DownloadFailed(format!(
"download already in progress for {id}" "download already in progress for {id}"
))); )));
} }
@@ -37,15 +37,15 @@ impl Drop for DownloadReservation {
} }
/// Resolve the models storage directory. /// Resolve the models storage directory.
/// Windows: %LOCALAPPDATA%/magnotia/models /// Windows: %LOCALAPPDATA%/lumotia/models
/// Unix: ~/.magnotia/models /// Unix: ~/.lumotia/models
pub fn models_dir() -> PathBuf { 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. /// Get the directory path where a specific model's files are stored.
pub fn model_dir(id: &ModelId) -> PathBuf { 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. /// 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. /// List all downloaded model IDs.
pub fn list_downloaded() -> Vec<ModelId> { pub fn list_downloaded() -> Vec<ModelId> {
magnotia_core::model_registry::all_models() lumotia_core::model_registry::all_models()
.iter() .iter()
.filter(|m| is_downloaded(&m.id)) .filter(|m| is_downloaded(&m.id))
.map(|m| m.id.clone()) .map(|m| m.id.clone())
@@ -74,13 +74,13 @@ pub fn list_downloaded() -> Vec<ModelId> {
/// For files that declare a `sha256` checksum we validate an existing /// For files that declare a `sha256` checksum we validate an existing
/// complete file before skipping the download — a truncated or /// complete file before skipping the download — a truncated or
/// tampered file gets redownloaded automatically (pattern ported from /// 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( pub async fn download(
id: &ModelId, id: &ModelId,
progress: impl Fn(DownloadProgress) + Send + 'static, progress: impl Fn(DownloadProgress) + Send + 'static,
) -> Result<()> { ) -> Result<()> {
let _reservation = DownloadReservation::acquire(id)?; 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); let dir = model_dir(id);
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
@@ -98,7 +98,7 @@ pub async fn download(
let _ = std::fs::remove_file(&dest); let _ = std::fs::remove_file(&dest);
} }
Err(e) => { Err(e) => {
return Err(MagnotiaError::DownloadFailed(format!( return Err(Error::DownloadFailed(format!(
"failed to verify existing {}: {e}", "failed to verify existing {}: {e}",
file.filename file.filename
))); )));
@@ -113,10 +113,13 @@ pub async fn download(
} }
fn verified_manifest_path(dir: &Path) -> PathBuf { 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)) { let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) {
Ok(contents) => contents, Ok(contents) => contents,
Err(_) => return false, Err(_) => return false,
@@ -137,7 +140,7 @@ fn verified_manifest_matches(entry: &magnotia_core::model_registry::ModelEntry,
} }
fn write_verified_manifest( fn write_verified_manifest(
entry: &magnotia_core::model_registry::ModelEntry, entry: &lumotia_core::model_registry::ModelEntry,
dir: &Path, dir: &Path,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
let mut lines = Vec::with_capacity(entry.files.len() + 1); let mut lines = Vec::with_capacity(entry.files.len() + 1);
@@ -193,7 +196,7 @@ async fn download_file(
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(30)) .connect_timeout(std::time::Duration::from_secs(30))
.build() .build()
.map_err(|e| MagnotiaError::DownloadFailed(e.to_string()))?; .map_err(|e| Error::DownloadFailed(e.to_string()))?;
// Check for existing partial download (resume support) // Check for existing partial download (resume support)
let existing_bytes = if part_path.exists() { let existing_bytes = if part_path.exists() {
@@ -212,12 +215,12 @@ async fn download_file(
let response = request let response = request
.send() .send()
.await .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 // If we requested Range but the server returned 200 (full file), the
// server does not support resume. Rather than blindly appending a // server does not support resume. Rather than blindly appending a
// full file on top of our partial bytes (which would produce 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. // ResumeUnsupported branch — item #8 of the brief.
// //
// For the non-resume path, we still have to validate the status: // For the non-resume path, we still have to validate the status:
@@ -234,14 +237,14 @@ async fn download_file(
false false
} }
other => { other => {
return Err(MagnotiaError::DownloadFailed(format!( return Err(Error::DownloadFailed(format!(
"resume request returned unexpected status {other}" "resume request returned unexpected status {other}"
))); )));
} }
} }
} else { } else {
if !response.status().is_success() { if !response.status().is_success() {
return Err(MagnotiaError::DownloadFailed(format!( return Err(Error::DownloadFailed(format!(
"download returned HTTP {} for {}", "download returned HTTP {} for {}",
response.status(), response.status(),
file.filename file.filename
@@ -288,7 +291,7 @@ async fn download_file(
} }
while let Some(chunk) = stream.next().await { 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)?; std::io::Write::write_all(&mut out, &chunk)?;
hasher.update(&chunk); hasher.update(&chunk);
downloaded += chunk.len() as u64; downloaded += chunk.len() as u64;
@@ -316,7 +319,7 @@ async fn download_file(
let actual = format!("{:x}", hasher.finalize()); let actual = format!("{:x}", hasher.finalize());
if actual != file.sha256 { if actual != file.sha256 {
let _ = std::fs::remove_file(&part_path); let _ = std::fs::remove_file(&part_path);
return Err(MagnotiaError::DownloadFailed(format!( return Err(Error::DownloadFailed(format!(
"SHA256 mismatch for {}: expected {}, got {}", "SHA256 mismatch for {}: expected {}, got {}",
file.filename, file.sha256, actual file.filename, file.sha256, actual
))); )));
@@ -354,7 +357,7 @@ mod tests {
let list = list_downloaded(); let list = list_downloaded();
// In test environment, no models are downloaded // In test environment, no models are downloaded
// This just verifies the function doesn't panic // 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] #[test]
@@ -478,7 +481,7 @@ mod tests {
let file = ModelFile { let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")), url: leak(format!("http://{addr}/fixture.bin")),
size: magnotia_core::types::Megabytes(0), size: lumotia_core::types::Megabytes(0),
sha256: leak(expected_sha.clone()), sha256: leak(expected_sha.clone()),
}; };
let id = ModelId::new("test-fixture"); let id = ModelId::new("test-fixture");
@@ -513,7 +516,7 @@ mod tests {
let file = ModelFile { let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")), url: leak(format!("http://{addr}/fixture.bin")),
size: magnotia_core::types::Megabytes(0), size: lumotia_core::types::Megabytes(0),
sha256: leak(expected_sha), sha256: leak(expected_sha),
}; };
let id = ModelId::new("test-fixture"); let id = ModelId::new("test-fixture");
@@ -568,7 +571,7 @@ mod tests {
let file = ModelFile { let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")), url: leak(format!("http://{addr}/fixture.bin")),
size: magnotia_core::types::Megabytes(0), size: lumotia_core::types::Megabytes(0),
sha256: leak("0".repeat(64)), sha256: leak("0".repeat(64)),
}; };
let id = ModelId::new("test-fixture"); let id = ModelId::new("test-fixture");
@@ -596,7 +599,7 @@ mod tests {
let file = ModelFile { let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")), url: leak(format!("http://{addr}/fixture.bin")),
size: magnotia_core::types::Megabytes(0), size: lumotia_core::types::Megabytes(0),
sha256: leak("deadbeef".repeat(8)), sha256: leak("deadbeef".repeat(8)),
}; };
let id = ModelId::new("test-fixture"); let id = ModelId::new("test-fixture");

View File

@@ -0,0 +1,281 @@
//! `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
//! Lumotia's local engines.
//!
//! `LocalProviderAdapter` lives here, not as an `impl
//! TranscriptionProvider for LocalEngine`. The adapter wraps an
//! `Arc<LocalEngine>` and bridges the synchronous `Transcriber` trait
//! to the async `TranscriptionProvider` interface via
//! `tokio::task::spawn_blocking`. Keeping the adapter in the
//! orchestrator (rather than in `local_engine.rs`) means the
//! transcription crate's core types do not need to depend on
//! `async_trait`, and the dependency edge stays one-directional:
//! `cloud_providers` defines the trait, `transcription` implements it
//! via adapter without leaking async-runtime requirements onto
//! `Transcriber`.
use std::sync::Arc;
use async_trait::async_trait;
use lumotia_cloud_providers::{
CostClass, EngineProfile, ProviderCapabilities, ProviderId, ProviderKind, ProviderTranscript,
TranscriptionProvider,
};
use lumotia_core::error::{Error, Result};
use lumotia_core::types::{AudioSamples, TranscriptionOptions};
use crate::local_engine::LocalEngine;
use crate::registry::EngineRegistry;
/// Wraps a `LocalEngine` and presents the async `TranscriptionProvider`
/// interface upward. One adapter per local engine instance; multiple
/// engines (Whisper, Parakeet) live as multiple adapters in the
/// registry.
pub struct LocalProviderAdapter {
provider_id: ProviderId,
engine: Arc<LocalEngine>,
}
impl LocalProviderAdapter {
pub fn new(provider_id: ProviderId, engine: Arc<LocalEngine>) -> Self {
Self {
provider_id,
engine,
}
}
/// Direct access to the wrapped engine. Used by warmup, model
/// management, and the GPU-sequencing guard, none of which want to
/// route through the async trait surface.
pub fn engine(&self) -> Arc<LocalEngine> {
self.engine.clone()
}
}
#[async_trait]
impl TranscriptionProvider for LocalProviderAdapter {
fn provider_id(&self) -> ProviderId {
self.provider_id.clone()
}
fn kind(&self) -> ProviderKind {
ProviderKind::Local
}
fn capabilities(&self) -> ProviderCapabilities {
let local = self.engine.capabilities();
ProviderCapabilities {
sample_rate: local
.map(|c| c.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,
streaming_supported: false,
cost_class: CostClass::Free,
}
}
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
if self.engine.is_loaded() {
Ok(())
} else {
Err(Error::EngineNotLoaded)
}
}
async fn transcribe(
&self,
audio: AudioSamples,
options: TranscriptionOptions,
) -> Result<ProviderTranscript> {
let engine = self.engine.clone();
let timed = tokio::task::spawn_blocking(move || engine.transcribe_sync(&audio, &options))
.await
.map_err(|e| Error::TranscriptionFailed(format!("Task join error: {e}")))??;
Ok(ProviderTranscript {
transcript: timed.transcript,
inference_ms: timed.inference_ms,
})
}
}
/// The single entry point through which all transcription flows.
/// Resolves a provider against the registry, runs `prepare` if the
/// caller has not already done so, and dispatches `transcribe`.
pub struct Orchestrator {
registry: Arc<EngineRegistry>,
}
impl Orchestrator {
pub fn new(registry: Arc<EngineRegistry>) -> Self {
Self { registry }
}
/// Underlying registry. Exposed so the UI can list providers and
/// query capabilities without going through a transcribe call.
pub fn registry(&self) -> Arc<EngineRegistry> {
self.registry.clone()
}
/// Resolve the provider for a profile. Returns a clear error when
/// the profile names an unregistered engine.
pub fn resolve(&self, profile: &EngineProfile) -> Result<Arc<dyn TranscriptionProvider>> {
self.registry
.get(&profile.engine_id)
.ok_or_else(|| Error::ProviderNotRegistered(profile.engine_id.to_string()))
}
/// Transcribe audio using the provider named in the profile. The
/// orchestrator builds `TranscriptionOptions` from the profile and
/// delegates to the provider.
///
/// Phase A scope: this is the new entry point. Existing call sites
/// (`commands/transcription.rs::transcribe_file`) still call
/// `LocalEngine` directly; their rewire is a follow-up commit so
/// the chunking + post-processing logic moves cleanly without
/// inflating this diff. See `KNOWN-ISSUES.md` KI-06.
pub async fn transcribe(
&self,
audio: AudioSamples,
profile: &EngineProfile,
) -> Result<ProviderTranscript> {
let provider = self.resolve(profile)?;
let options = profile.to_options();
provider.transcribe(audio, options).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use lumotia_core::types::{Segment, Transcript};
/// Mock provider that returns a canned transcript and counts
/// invocations. Used to validate the orchestrator's dispatch logic
/// without booting a real model.
struct CannedProvider {
id: ProviderId,
calls: Arc<AtomicUsize>,
canned_text: String,
}
#[async_trait]
impl TranscriptionProvider for CannedProvider {
fn provider_id(&self) -> ProviderId {
self.id.clone()
}
fn kind(&self) -> ProviderKind {
ProviderKind::Local
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
sample_rate: 16_000,
channels: 1,
initial_prompt_supported: true,
language_hint_supported: true,
streaming_supported: false,
cost_class: CostClass::Free,
}
}
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
Ok(())
}
async fn transcribe(
&self,
audio: AudioSamples,
_options: TranscriptionOptions,
) -> Result<ProviderTranscript> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(ProviderTranscript {
transcript: Transcript::new(
vec![Segment {
start: 0.0,
end: audio.duration_secs(),
text: self.canned_text.clone(),
}],
"en".to_string(),
audio.duration_secs(),
),
inference_ms: 7,
})
}
}
fn build_registry_with(id: &str, text: &str, calls: Arc<AtomicUsize>) -> Arc<EngineRegistry> {
let mut registry = EngineRegistry::new(ProviderId::new(id));
registry.register(Arc::new(CannedProvider {
id: ProviderId::new(id),
calls,
canned_text: text.to_string(),
}));
Arc::new(registry)
}
fn one_second_of_silence() -> AudioSamples {
AudioSamples::mono_16khz(vec![0.0_f32; 16_000])
}
#[tokio::test]
async fn orchestrator_dispatches_to_named_provider() {
let calls = Arc::new(AtomicUsize::new(0));
let registry = build_registry_with("canned", "hello lumotia", calls.clone());
let orchestrator = Orchestrator::new(registry);
let profile = EngineProfile::new(ProviderId::new("canned"));
let result = orchestrator
.transcribe(one_second_of_silence(), &profile)
.await
.expect("dispatch succeeds");
assert_eq!(result.transcript.text(), "hello lumotia");
assert_eq!(result.inference_ms, 7);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn orchestrator_returns_clear_error_for_unregistered_provider() {
let calls = Arc::new(AtomicUsize::new(0));
let registry = build_registry_with("canned", "_", calls);
let orchestrator = Orchestrator::new(registry);
let profile = EngineProfile::new(ProviderId::new("not-registered"));
let err = orchestrator
.transcribe(one_second_of_silence(), &profile)
.await
.expect_err("unregistered provider must error");
let msg = err.to_string();
assert!(
msg.contains("not-registered"),
"error must name the missing provider, got: {msg}"
);
}
#[tokio::test]
async fn orchestrator_routes_initial_prompt_through_options() {
let calls = Arc::new(AtomicUsize::new(0));
let registry = build_registry_with("canned", "transcript", calls.clone());
let orchestrator = Orchestrator::new(registry);
let mut profile = EngineProfile::new(ProviderId::new("canned"));
profile.language = Some("en".to_string());
profile.initial_prompt = Some("Lumotia".to_string());
let _ = orchestrator
.transcribe(one_second_of_silence(), &profile)
.await
.expect("dispatch succeeds");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn local_provider_adapter_is_object_safe() {
let _: Option<Arc<dyn TranscriptionProvider>> = None;
}
}

View File

@@ -0,0 +1,183 @@
//! `EngineRegistry` is the catalogue of available providers, built
//! once at app boot and dependency-injected into the orchestrator.
//!
//! Registration is push-style: the boot code constructs each provider
//! (a `LocalProviderAdapter` wrapping a `LocalEngine`, or a cloud
//! provider implementing `TranscriptionProvider` directly) and calls
//! `register`. The default provider is set at construction time and
//! used when a profile does not specify one.
//!
//! The registry is read-only after boot; mutation requires a fresh
//! registry instance. Providers are held behind `Arc<dyn
//! TranscriptionProvider>` so the orchestrator can clone the handle
//! cheaply across tokio tasks.
use std::collections::HashMap;
use std::sync::Arc;
use lumotia_cloud_providers::{ProviderId, TranscriptionProvider};
/// Catalogue of providers known to the orchestrator.
pub struct EngineRegistry {
providers: HashMap<ProviderId, Arc<dyn TranscriptionProvider>>,
default_provider: ProviderId,
}
impl EngineRegistry {
/// Construct an empty registry with the given default provider id.
/// The default need not exist at construction time; callers are
/// expected to register it before the registry is queried.
pub fn new(default_provider: ProviderId) -> Self {
Self {
providers: HashMap::new(),
default_provider,
}
}
/// Register a provider. If a provider with the same id is already
/// registered, it is replaced. The provider's id is read once via
/// `provider.provider_id()` and used as the registry key.
pub fn register(&mut self, provider: Arc<dyn TranscriptionProvider>) {
let id = provider.provider_id();
self.providers.insert(id, provider);
}
/// Fetch a provider by id. Returns `None` when the id is not in the
/// registry; the orchestrator surfaces this as a clear error so the
/// UI can prompt the user to pick a different engine.
pub fn get(&self, id: &ProviderId) -> Option<Arc<dyn TranscriptionProvider>> {
self.providers.get(id).cloned()
}
/// Fetch the default provider. Returns `None` if the default has
/// not been registered.
pub fn default(&self) -> Option<Arc<dyn TranscriptionProvider>> {
self.providers.get(&self.default_provider).cloned()
}
/// The id of the default provider. Always returns; the provider
/// itself may not be registered.
pub fn default_id(&self) -> &ProviderId {
&self.default_provider
}
/// All registered provider ids. The order is unspecified; the UI
/// is responsible for any sorting it wants to present.
pub fn ids(&self) -> Vec<ProviderId> {
self.providers.keys().cloned().collect()
}
/// Number of registered providers.
pub fn len(&self) -> usize {
self.providers.len()
}
pub fn is_empty(&self) -> bool {
self.providers.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use lumotia_cloud_providers::{
CostClass, EngineProfile, ProviderCapabilities, ProviderKind, ProviderTranscript,
};
use lumotia_core::error::Result;
use lumotia_core::types::{AudioSamples, Transcript, TranscriptionOptions};
struct DummyProvider {
id: ProviderId,
}
#[async_trait]
impl TranscriptionProvider for DummyProvider {
fn provider_id(&self) -> ProviderId {
self.id.clone()
}
fn kind(&self) -> ProviderKind {
ProviderKind::Local
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
sample_rate: 16_000,
channels: 1,
initial_prompt_supported: false,
language_hint_supported: true,
streaming_supported: false,
cost_class: CostClass::Free,
}
}
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
Ok(())
}
async fn transcribe(
&self,
audio: AudioSamples,
_options: TranscriptionOptions,
) -> Result<ProviderTranscript> {
Ok(ProviderTranscript {
transcript: Transcript::new(Vec::new(), "en".to_string(), audio.duration_secs()),
inference_ms: 0,
})
}
}
fn dummy(id: &str) -> Arc<dyn TranscriptionProvider> {
Arc::new(DummyProvider {
id: ProviderId::new(id),
})
}
#[test]
fn empty_registry_returns_none_for_default() {
let registry = EngineRegistry::new(ProviderId::new("local-whisper"));
assert!(registry.default().is_none());
assert_eq!(registry.default_id().as_str(), "local-whisper");
assert!(registry.is_empty());
}
#[test]
fn register_and_get_round_trip() {
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
registry.register(dummy("local-whisper"));
registry.register(dummy("local-parakeet"));
assert_eq!(registry.len(), 2);
assert!(registry.get(&ProviderId::new("local-whisper")).is_some());
assert!(registry.get(&ProviderId::new("local-parakeet")).is_some());
assert!(registry.get(&ProviderId::new("nonexistent")).is_none());
}
#[test]
fn default_resolves_after_registration() {
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
registry.register(dummy("local-whisper"));
let default = registry.default().expect("default provider registered");
assert_eq!(default.provider_id().as_str(), "local-whisper");
}
#[test]
fn re_register_replaces() {
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
registry.register(dummy("local-whisper"));
registry.register(dummy("local-whisper"));
assert_eq!(registry.len(), 1);
}
#[test]
fn ids_lists_all_registered() {
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
registry.register(dummy("local-whisper"));
registry.register(dummy("local-parakeet"));
let mut ids: Vec<String> = registry
.ids()
.into_iter()
.map(|id| id.as_str().to_string())
.collect();
ids.sort();
assert_eq!(ids, vec!["local-parakeet", "local-whisper"]);
}
}

View File

@@ -9,8 +9,8 @@
//! `whisper` feature — `WhisperRsBackend` (direct whisper-rs, the only //! `whisper` feature — `WhisperRsBackend` (direct whisper-rs, the only
//! path that pipes `initial_prompt`). //! path that pipes `initial_prompt`).
use magnotia_core::error::Result; use lumotia_core::error::Result;
use magnotia_core::types::{Segment, TranscriptionOptions}; use lumotia_core::types::{Segment, TranscriptionOptions};
/// Static capabilities a `Transcriber` advertises to callers. /// Static capabilities a `Transcriber` advertises to callers.
/// ///

View File

@@ -10,10 +10,10 @@ use std::path::Path;
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
use magnotia_core::error::{MagnotiaError, Result}; use lumotia_core::error::{Error, Result};
use magnotia_core::hardware::vulkan_loader_available; use lumotia_core::hardware::vulkan_loader_available;
use magnotia_core::tuning::{inference_thread_count, Workload}; use lumotia_core::tuning::{inference_thread_count, Workload};
use magnotia_core::types::{Segment, TranscriptionOptions}; use lumotia_core::types::{Segment, TranscriptionOptions};
use crate::transcriber::{Transcriber, TranscriberCapabilities}; use crate::transcriber::{Transcriber, TranscriberCapabilities};
@@ -42,7 +42,7 @@ impl WhisperRsBackend {
impl Transcriber for WhisperRsBackend { impl Transcriber for WhisperRsBackend {
fn capabilities(&self) -> TranscriberCapabilities { fn capabilities(&self) -> TranscriberCapabilities {
TranscriberCapabilities { TranscriberCapabilities {
sample_rate: magnotia_core::constants::WHISPER_SAMPLE_RATE, sample_rate: lumotia_core::constants::WHISPER_SAMPLE_RATE,
channels: 1, channels: 1,
supports_initial_prompt: true, supports_initial_prompt: true,
} }
@@ -65,7 +65,9 @@ impl Transcriber for WhisperRsBackend {
); );
let mut state = self.ctx.create_state().map_err(|e| { 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 }); let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
@@ -86,7 +88,7 @@ impl Transcriber for WhisperRsBackend {
params.set_print_realtime(false); params.set_print_realtime(false);
state.full(params, samples).map_err(|e| { state.full(params, samples).map_err(|e| {
MagnotiaError::TranscriptionFailed( Error::TranscriptionFailed(
WhisperBackendError::Transcribe(e.to_string()).to_string(), WhisperBackendError::Transcribe(e.to_string()).to_string(),
) )
})?; })?;
@@ -101,7 +103,7 @@ impl Transcriber for WhisperRsBackend {
let text = seg let text = seg
.to_str() .to_str()
.map_err(|e| { .map_err(|e| {
MagnotiaError::TranscriptionFailed( Error::TranscriptionFailed(
WhisperBackendError::Transcribe(e.to_string()).to_string(), WhisperBackendError::Transcribe(e.to_string()).to_string(),
) )
})? })?

View File

@@ -2,20 +2,20 @@
//! Reports cold-load time, transcribe time, RTF, peak RSS. //! Reports cold-load time, transcribe time, RTF, peak RSS.
//! //!
//! Gated on env vars so it never runs in CI without setup: //! Gated on env vars so it never runs in CI without setup:
//! MAGNOTIA_WHISPER_TEST_MODEL=/path/to/ggml-tiny.bin //! LUMOTIA_WHISPER_TEST_MODEL=/path/to/ggml-tiny.bin
//! MAGNOTIA_WHISPER_TEST_AUDIO=/path/to/jfk.wav //! LUMOTIA_WHISPER_TEST_AUDIO=/path/to/jfk.wav
use std::env; use std::env;
use std::time::Instant; use std::time::Instant;
#[test] #[test]
fn jfk_transcription_benchmark() { fn jfk_transcription_benchmark() {
let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else { let Ok(model_path) = env::var("LUMOTIA_WHISPER_TEST_MODEL") else {
eprintln!("MAGNOTIA_WHISPER_TEST_MODEL not set — skipping"); eprintln!("LUMOTIA_WHISPER_TEST_MODEL not set — skipping");
return; return;
}; };
let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else { let Ok(audio_path) = env::var("LUMOTIA_WHISPER_TEST_AUDIO") else {
eprintln!("MAGNOTIA_WHISPER_TEST_AUDIO not set — skipping"); eprintln!("LUMOTIA_WHISPER_TEST_AUDIO not set — skipping");
return; return;
}; };
@@ -30,7 +30,10 @@ fn jfk_transcription_benchmark() {
let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap()); let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
let channels = u16::from_le_bytes(bytes[22..24].try_into().unwrap()); let channels = u16::from_le_bytes(bytes[22..24].try_into().unwrap());
let bits = u16::from_le_bytes(bytes[34..36].try_into().unwrap()); let bits = u16::from_le_bytes(bytes[34..36].try_into().unwrap());
eprintln!("[bench] wav spec: {} Hz, {} ch, {}-bit", sample_rate, channels, bits); eprintln!(
"[bench] wav spec: {} Hz, {} ch, {}-bit",
sample_rate, channels, bits
);
assert_eq!(sample_rate, 16_000, "expected 16 kHz wav"); assert_eq!(sample_rate, 16_000, "expected 16 kHz wav");
assert_eq!(channels, 1, "expected mono"); assert_eq!(channels, 1, "expected mono");
assert_eq!(bits, 16, "expected 16-bit PCM"); assert_eq!(bits, 16, "expected 16-bit PCM");
@@ -48,7 +51,10 @@ fn jfk_transcription_benchmark() {
); );
let rss_before_load_kb = read_rss_kb(); let rss_before_load_kb = read_rss_kb();
eprintln!("[bench] RSS before model load: {} MB", rss_before_load_kb / 1024); eprintln!(
"[bench] RSS before model load: {} MB",
rss_before_load_kb / 1024
);
let load_start = Instant::now(); let load_start = Instant::now();
let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default()) let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default())
@@ -57,9 +63,11 @@ fn jfk_transcription_benchmark() {
eprintln!("[bench] model load: {:.2}s", load_dur.as_secs_f64()); eprintln!("[bench] model load: {:.2}s", load_dur.as_secs_f64());
let rss_after_load_kb = read_rss_kb(); let rss_after_load_kb = read_rss_kb();
eprintln!("[bench] RSS after model load: {} MB (delta +{} MB)", eprintln!(
"[bench] RSS after model load: {} MB (delta +{} MB)",
rss_after_load_kb / 1024, rss_after_load_kb / 1024,
(rss_after_load_kb.saturating_sub(rss_before_load_kb)) / 1024); (rss_after_load_kb.saturating_sub(rss_before_load_kb)) / 1024
);
let mut state = ctx.create_state().expect("whisper state"); let mut state = ctx.create_state().expect("whisper state");
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
@@ -110,12 +118,20 @@ fn jfk_transcription_benchmark() {
let rss_final_kb = read_rss_kb(); let rss_final_kb = read_rss_kb();
eprintln!("[bench] RSS final: {} MB", rss_final_kb / 1024); eprintln!("[bench] RSS final: {} MB", rss_final_kb / 1024);
eprintln!(""); eprintln!();
eprintln!("=== SUMMARY ==="); eprintln!("=== SUMMARY ===");
eprintln!("audio: {:.2}s", audio_secs); eprintln!("audio: {:.2}s", audio_secs);
eprintln!("model_load: {:.2}s", load_dur.as_secs_f64()); eprintln!("model_load: {:.2}s", load_dur.as_secs_f64());
eprintln!("cold xc: {:.2}s RTF={:.3}", cold_dur.as_secs_f64(), cold_dur.as_secs_f64() / audio_secs); eprintln!(
eprintln!("warm xc: {:.2}s RTF={:.3}", warm_dur.as_secs_f64(), warm_dur.as_secs_f64() / audio_secs); "cold xc: {:.2}s RTF={:.3}",
cold_dur.as_secs_f64(),
cold_dur.as_secs_f64() / audio_secs
);
eprintln!(
"warm xc: {:.2}s RTF={:.3}",
warm_dur.as_secs_f64(),
warm_dur.as_secs_f64() / audio_secs
);
eprintln!("RSS peak: {} MB", rss_final_kb / 1024); eprintln!("RSS peak: {} MB", rss_final_kb / 1024);
} }
@@ -124,7 +140,9 @@ fn read_rss_kb() -> u64 {
let s = std::fs::read_to_string(format!("/proc/{pid}/status")).unwrap_or_default(); let s = std::fs::read_to_string(format!("/proc/{pid}/status")).unwrap_or_default();
for line in s.lines() { for line in s.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") { if let Some(rest) = line.strip_prefix("VmRSS:") {
return rest.trim().split_whitespace().next() return rest
.split_whitespace()
.next()
.and_then(|n| n.parse::<u64>().ok()) .and_then(|n| n.parse::<u64>().ok())
.unwrap_or(0); .unwrap_or(0);
} }

View File

@@ -1,24 +1,24 @@
//! Thread-count scaling sweep for Whisper Tiny. //! Thread-count scaling sweep for Whisper Tiny.
//! Runs the JFK clip at n_threads = 1, 2, 4, 6, 8, 12, prints RTF tables. //! 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 //! the helper's predicted thread count for each (power, GPU) combination
//! can be compared against the empirical RTF data. //! can be compared against the empirical RTF data.
use std::env; use std::env;
use std::time::Instant; use std::time::Instant;
use magnotia_core::hardware::vulkan_loader_available; use lumotia_core::hardware::vulkan_loader_available;
use magnotia_core::tuning::{inference_thread_count, Workload}; use lumotia_core::tuning::{inference_thread_count, Workload};
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
#[test] #[test]
fn whisper_thread_count_sweep() { 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; return;
}; };
let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else { let Ok(audio_path) = env::var("LUMOTIA_WHISPER_TEST_AUDIO") else {
return; return;
}; };
@@ -71,7 +71,7 @@ fn whisper_thread_count_sweep() {
); );
// Four panels: CPU and GPU axes for the predicted-helper-pick column, // 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 = [ let panels = [
("AC, CPU", "ac", false), ("AC, CPU", "ac", false),
("AC, GPU (Vulkan)", "ac", true), ("AC, GPU (Vulkan)", "ac", true),
@@ -80,18 +80,11 @@ fn whisper_thread_count_sweep() {
]; ];
for (label, power, gpu_offloaded_for_helper) in panels { 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); let helper_pick = inference_thread_count(Workload::Whisper, gpu_offloaded_for_helper);
run_sweep_panel( run_sweep_panel(label, helper_pick, &ctx, &samples, audio_secs, &targets);
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( fn run_sweep_panel(
@@ -102,10 +95,8 @@ fn run_sweep_panel(
audio_secs: f64, audio_secs: f64,
targets: &[i32], targets: &[i32],
) { ) {
eprintln!(""); eprintln!();
eprintln!( eprintln!("=== n_threads scaling: {label} (helper picks: {helper_pick}) ===");
"=== n_threads scaling: {label} (helper picks: {helper_pick}) ==="
);
eprintln!("n_threads | xc_time | RTF | speedup_vs_1"); eprintln!("n_threads | xc_time | RTF | speedup_vs_1");
eprintln!("----------|---------|--------|-------------"); eprintln!("----------|---------|--------|-------------");
let mut baseline_dur: Option<f64> = None; let mut baseline_dur: Option<f64> = None;

View File

@@ -1,17 +1,17 @@
//! Smoke test: whisper-rs 0.16 loads a GGUF model, transcribes silence, and //! Smoke test: whisper-rs 0.16 loads a GGUF model, transcribes silence, and
//! accepts set_initial_prompt without panicking. //! 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. //! ggml/gguf whisper model on disk. Otherwise the test exits quiet.
use std::env; use std::env;
#[test] #[test]
fn whisper_rs_smoke_loads_and_transcribes() { 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, Ok(p) => p,
Err(_) => { Err(_) => {
eprintln!("MAGNOTIA_WHISPER_TEST_MODEL not set — skipping"); eprintln!("LUMOTIA_WHISPER_TEST_MODEL not set — skipping");
return; return;
} }
}; };

View File

@@ -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. - **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. - **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. - **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`). - **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 ## Map of this slice
@@ -44,21 +44,21 @@ last_verified: 2026/05/09
**In (frontend depends on Tauri runtime, slice 02).** **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). - 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`. - Window APIs: `getCurrentWindow().minimize() / toggleMaximize() / setPosition() / label`.
- Plugin imports loaded lazily: `@tauri-apps/plugin-global-shortcut`, `@tauri-apps/plugin-dialog`. - Plugin imports loaded lazily: `@tauri-apps/plugin-global-shortcut`, `@tauri-apps/plugin-dialog`.
**Out (frontend triggers behaviour back into the runtime).** **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. - 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 `magnotia:preferences-changed` only, to fan preference updates across windows. - 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. - 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.** **Other slices that read frontend conventions.**
- Slice 02 (Tauri runtime) emits the events listed above and registers commands the frontend invokes. - 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 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. - Slice 05 (storage, hotkey, build) supplies `add_transcript`, `delete_transcript`, profiles, tasks and the evdev hotkey backend.
## Existing in repo docs (do not duplicate) ## 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. 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. 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. 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). 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. 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. 9. **`speaker.svelte.ts` is ten lines.** A near empty store. Used by `SpeakerButton.svelte`. Consider folding into a util if it never grows.

View File

@@ -39,7 +39,7 @@ Caches OS info via `invoke("os_info")` (or similar; check `lib.rs`). Exposes `lo
### `settingsMigrations.ts` (134 LOC) ### `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) ### `frontmatter.ts` (148 LOC)

View File

@@ -22,7 +22,7 @@ last_verified: 2026/05/09
### `src/app.html` (13 LOC) ### `src/app.html` (13 LOC)
Standard SvelteKit document. Sets `lang="en"`, charset, viewport, favicon, title (`Magnotia`), and applies `data-sveltekit-preload-data="hover"` on `<body>`. The body content is wrapped in `<div style="display: contents">` 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 `<body>`. The body content is wrapped in `<div style="display: contents">` so the SvelteKit-injected children render inline.
### `src/app.d.ts` (8 LOC) ### `src/app.d.ts` (8 LOC)

View File

@@ -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. | | `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`. | | `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. | | `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. | | `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. | | `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 | | 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`). | | `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 (`magnotia:microstep-generated`), step completion (`magnotia:step-completed`), per task implementation rules. | | `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. | | `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. | | `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. | | `VisualTimer` | 33 | Compact visual timer pill used inside MicroSteps and the float window. |

View File

@@ -9,14 +9,14 @@ last_verified: 2026/05/09
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Design system > **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 ## At a glance
- **Path:** `src/design-system/` - **Path:** `src/design-system/`
- **Files:** - **Files:**
- `README.md`. Brand and content rules. (Magnotia by CORBEL.) - `README.md`. Brand and content rules. (Lumotia 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. - `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. - `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. - `preview/`. 20 buildless HTML spec cards.
- `ui_kits/`. JSX recreations (`DictationPage.jsx`, `OtherPages.jsx`, `Sidebar.jsx`) plus an `index.html` and a README. - `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/` ## 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 ## Watch outs

View File

@@ -15,8 +15,8 @@ last_verified: 2026/05/09
- **Direction in (Rust → frontend):** events listed under "Tauri events listened to". - **Direction in (Rust → frontend):** events listed under "Tauri events listened to".
- **Direction out (frontend → Rust):** all `invoke()` commands listed under "Tauri commands invoked". - **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. - **DOM-only events:** `lumotia:*` `CustomEvent`s on `window`. These never cross the Rust boundary.
- **Cross-window event:** `magnotia:preferences-changed` is the only one that goes through `emit()`. - **Cross-window event:** `lumotia:preferences-changed` is the only one that goes through `emit()`.
## Tauri commands invoked (alphabetical) ## Tauri commands invoked (alphabetical)
@@ -91,10 +91,10 @@ Plus any commands invoked by `saveMarkdown.ts` via the dialog/file-write plugin
| Event | Listener | | Event | Listener |
|---|---| |---|---|
| `magnotia:hotkey-pressed` | `routes/+layout.svelte:177` (evdev backend) | | `lumotia:hotkey-pressed` | `routes/+layout.svelte:177` (evdev backend) |
| `magnotia:llm-download-progress` | `pages/SettingsPage.svelte:873` | | `lumotia:llm-download-progress` | `pages/SettingsPage.svelte:873` |
| `magnotia:open-wind-down` | `routes/+layout.svelte:251` (tray menu hook) | | `lumotia: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: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` | | `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` | | `parakeet-download-progress` | `pages/SettingsPage.svelte:880`, `pages/FirstRunPage.svelte:50` |
| `preview-cleanup` | `routes/preview/+page.svelte:141` | | `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 | | 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-append` | `pages/DictationPage.svelte:165` | Stream partial text to overlay window. |
| `preview-listening` | `pages/DictationPage.svelte:381` | Tell overlay to enter listening state. | | `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-cleanup` | `pages/DictationPage.svelte:531` | Tell overlay to enter cleanup state. |
| `preview-final` | `pages/DictationPage.svelte:539` | Tell overlay to render final text. | | `preview-final` | `pages/DictationPage.svelte:539` | Tell overlay to render final text. |
| `preview-hide` | `pages/DictationPage.svelte:606` | Tell overlay to dismiss. | | `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) | | Event | Dispatcher | Listener(s) |
|---|---|---| |---|---|---|
| `magnotia:focus-timer-cancelled` | `stores/focusTimer.svelte.ts` | `completionStats` (indirectly), components subscribed to focus timer. | | `lumotia: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. | | `lumotia:focus-timer-complete` | `stores/focusTimer.svelte.ts` | Same as above. |
| `magnotia:implementation-rules-changed` | `stores/implementationIntentions.svelte.ts` | `ImplementationRulesEditor`. | | `lumotia:implementation-rules-changed` | `stores/implementationIntentions.svelte.ts` | `ImplementationRulesEditor`. |
| `magnotia:microstep-generated` | `stores/nudgeBus.svelte.ts` (around micro step generation) | `MicroSteps.svelte`. | | `lumotia:microstep-generated` | `stores/nudgeBus.svelte.ts` (around micro step generation) | `MicroSteps.svelte`. |
| `magnotia:morning-triage-finished` | `MorningTriageModal.svelte` | `nudgeBus`. | | `lumotia:morning-triage-finished` | `MorningTriageModal.svelte` | `nudgeBus`. |
| `magnotia:start-timer` | `WipTaskList.svelte`, `MicroSteps.svelte` | `FocusTimer.svelte` + `focusTimer` store. | | `lumotia:start-timer` | `WipTaskList.svelte`, `MicroSteps.svelte` | `FocusTimer.svelte` + `focusTimer` store. |
| `magnotia:step-completed` | `MicroSteps.svelte` | `completionStats` refresh. | | `lumotia:step-completed` | `MicroSteps.svelte` | `completionStats` refresh. |
| `magnotia:task-completed` | `stores/page.svelte.ts` (`completeTask`) | `completionStats`. | | `lumotia:task-completed` | `stores/page.svelte.ts` (`completeTask`) | `completionStats`. |
| `magnotia:task-deleted` | `stores/page.svelte.ts` (`deleteTask`) | `completionStats`. | | `lumotia:task-deleted` | `stores/page.svelte.ts` (`deleteTask`) | `completionStats`. |
| `magnotia:task-uncompleted` | `stores/page.svelte.ts` (`uncompleteTask`) | `completionStats`. | | `lumotia:task-uncompleted` | `stores/page.svelte.ts` (`uncompleteTask`) | `completionStats`. |
| `magnotia:toggle-recording` | `routes/+layout.svelte` (after hotkey debounce, both backends) | `pages/DictationPage.svelte`. | | `lumotia:toggle-recording` | `routes/+layout.svelte` (after hotkey debounce, both backends) | `pages/DictationPage.svelte`. |
## Window APIs ## Window APIs
@@ -151,8 +151,8 @@ Plus any commands invoked by `saveMarkdown.ts` via the dialog/file-write plugin
## Watch outs ## 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 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. - 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.
- `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. - `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. - `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. - DOM `CustomEvent` handlers do not survive across windows (they are window-local). The tasks list refresh trick on focus relies on this.

View File

@@ -38,7 +38,7 @@ export const SUPPORTED_LOCALES: { code: Locale; label: string }[] = [
{ code: "de", label: "Deutsch" }, { code: "de", label: "Deutsch" },
]; ];
const STORAGE_KEY = "magnotia_locale"; const STORAGE_KEY = "lumotia_locale";
register("en", () => import("./locales/en.json")); register("en", () => import("./locales/en.json"));
register("es", () => import("./locales/es.json")); register("es", () => import("./locales/es.json"));
@@ -49,7 +49,7 @@ function detectInitialLocale(): Locale { /* localStorage → navigator.language
Public exports: Public exports:
- `initI18n()`. Idempotent. Called from every layout's `onMount`-ish path (`+layout.svelte:38` and the float/viewer/preview break layouts). - `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`. - `currentLocale`. A derived store that components subscribe to via `$currentLocale`.
- `SUPPORTED_LOCALES` constant for the picker. - `SUPPORTED_LOCALES` constant for the picker.
@@ -65,7 +65,7 @@ Public exports:
## Watch outs ## Watch outs
- Adding a new locale is one `register("xx", () => import("./locales/xx.json"))` call plus a `SUPPORTED_LOCALES` entry. - 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. - 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. - Default locale is detected from `navigator.language`. In Tauri, this is the OS locale.

View File

@@ -9,7 +9,7 @@ last_verified: 2026/05/09
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Pages overview > **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 ## 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. | | 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`. | | 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. | | 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) ### Secondary windows (URL routed)

View File

@@ -39,12 +39,12 @@ last_verified: 2026/05/09
### Lifecycle ### 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. - `onDestroy`. Tears down listeners and timers.
### Recording flow (high level) ### 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`). 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<message>` instances (`DictationPage.svelte:341-342`) and call `start_live_transcription_session` with the channel handles. 3. Open two `Channel<message>` 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. 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.

View File

@@ -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 > **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 ## At a glance

View File

@@ -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`. - `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`). - 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 ## 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. 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 ## Watch outs

View File

@@ -43,7 +43,7 @@ last_verified: 2026/05/09
### Events ### 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) ## Tauri command surface (direct)
@@ -54,7 +54,7 @@ The store helpers used here call into Rust through `add_task_cmd`, `complete_tas
## Watch outs ## 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. - 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`. - "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. - Sort, filter, search are not persisted. Reload returns to defaults.

View File

@@ -9,7 +9,7 @@ last_verified: 2026/05/09
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Stores > **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 ## At a glance
@@ -17,8 +17,8 @@ last_verified: 2026/05/09
- **Files:** 10 (4 085 LOC across stores + utils + types). - **Files:** 10 (4 085 LOC across stores + utils + types).
- **Cross window sync:** - **Cross window sync:**
- `localStorage` `storage` event in the float window for settings. - `localStorage` `storage` event in the float window for settings.
- Tauri `emit("magnotia:preferences-changed")` for preferences (handled by every layout). - Tauri `emit("lumotia: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. - **Persistence keys:** `lumotia_settings`, `lumotia_profiles`, `lumotia_task_lists`, `lumotia_templates`, `lumotia_locale`, plus a viewer handoff key.
## The stores ## The stores
@@ -26,7 +26,7 @@ last_verified: 2026/05/09
Owns: Owns:
- `page` (`PageState`): `current`, `status`, `statusColor`, `activeProfile`, `recording`, `timerText`, `handedness`, `taskSidebarOpen`. Initial: `current = "dictation"`. - `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. - `profiles`, `templates`, `taskLists` arrays.
- `tasks` and `history` arrays. - `tasks` and `history` arrays.
@@ -34,7 +34,7 @@ Helpers (selected, with line refs):
- `addToHistory``add_transcript` (`page.svelte.ts:234`). - `addToHistory``add_transcript` (`page.svelte.ts:234`).
- `mapTranscriptRow`, `saveTranscriptMeta``update_transcript` (`page.svelte.ts:272`). - `mapTranscriptRow`, `saveTranscriptMeta``update_transcript` (`page.svelte.ts:272`).
- `deleteFromHistoryById``delete_transcript` (`page.svelte.ts:320`). - `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`. - Task lists: `addTaskList`, `renameTaskList`, `deleteTaskList`, `moveTaskList`, `sortTaskLists`, `moveTaskToList`, `moveTaskListToProfile`.
- Profile mutators: `addProfileTaskList`, `removeProfileTaskList`. - Profile mutators: `addProfileTaskList`, `removeProfileTaskList`.
- `saveSettings`, `saveProfiles`, `saveTemplates` write to `localStorage`. - `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 `<html>` data attributes and CSS variables; `applyToDOM(prefs)` writes them. - DOM is the source of truth at runtime: `readFromDOM()` reads `<html>` 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`). - 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. - Exposes `getPreferences`, `updatePreferences`, `updateAccessibility`, `applyExternalPreferences`, `PREFERENCES_CHANGED_EVENT` constant.
- Font families resolved from a constant map (Lexend, Atkinson, OpenDyslexic). - 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. 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). ### `focusTimer.svelte.ts` (238 LOC).
Owns the floating focus timer. State: target ms, started at, label, taskId, paused. Owns the floating focus timer. State: target ms, started at, label, taskId, paused.
- `setInterval` at 250 ms (`TICK_INTERVAL_MS`). - `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). ### `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. - 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). - 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). - 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`. - Lifecycle: `startImplementationIntentions`, `stopImplementationIntentions` from `+layout.svelte`.
### `nudgeBus.svelte.ts` (292 LOC). ### `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. - Plus a `microStepTimers` Map for per task delayed notifications.
Key commands: `deliver_nudge` (line 112), `tts_speak` (line 119). 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`. Lifecycle: `startNudgeBus`, `stopNudgeBus` from `+layout.svelte`.
### `speaker.svelte.ts` (10 LOC). ### `speaker.svelte.ts` (10 LOC).
@@ -111,9 +111,9 @@ Tiny. Holds `speakingId` so `SpeakerButton` instances can debounce / cancel each
## Cross store traffic ## Cross store traffic
- `DictationPage.completeRecording()``addToHistory` (page) → `add_transcript` Rust → push to history. - `DictationPage.completeRecording()``addToHistory` (page) → `add_transcript` Rust → push to history.
- `MicroSteps` "start timer" button → `magnotia:start-timer``focusTimer` store transitions → `FocusTimer` component renders ring. - `MicroSteps` "start timer" button → `lumotia:start-timer``focusTimer` store transitions → `FocusTimer` component renders ring.
- `TasksPage.addTask``tasks` store → emits `magnotia:task-completed/...``completionStats` refreshes → `CompletionSparkline` re renders. - `TasksPage.addTask``tasks` store → emits `lumotia:task-completed/...``completionStats` refreshes → `CompletionSparkline` re renders.
- `SettingsPage` accessibility toggle → `updatePreferences` → DOM write + `save_preferences` invoke + `magnotia:preferences-changed` emit → secondary windows mirror. - `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. - Float window settings sync uses `localStorage` `storage` event, not the Tauri preference event. Drift candidate.
## Watch outs ## 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. - `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`. - `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. - `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 ## See also

View File

@@ -9,7 +9,7 @@ last_verified: 2026/05/09
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Windows and routes > **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 ## 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`). - 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 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`). - 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. - 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 `<body>` from `settings.fontSize`. - Font size CSS variable `--font-size-transcript` set on `<body>` from `settings.fontSize`.
- Global error capture. `window.onerror` and `unhandledrejection` forward to `log_frontend_error` Rust command. Best effort, swallow throws. - 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) ### `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) ### `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 `<audio>` element using `convertFileSrc()` to map the saved audio path to a webview URL. Provides per segment editing, debounced autosave through `saveTranscriptMeta`, virtual scrolling via `VirtualSegmentList`, playback speed control (`PLAYBACK_SPEEDS`), starring and tagging. Transcript editor. Hydrates from a transcript ID handed off via `localStorage` (`lumotia:viewer-handoff` style key, see file). Fetches the full row from SQLite via `get_transcript`. Renders an `<audio>` element using `convertFileSrc()` to map the saved audio path to a webview URL. Provides per segment editing, debounced autosave through `saveTranscriptMeta`, virtual scrolling via `VirtualSegmentList`, playback speed control (`PLAYBACK_SPEEDS`), starring and tagging.
### `src/routes/preview/+layout@.svelte` (68 LOC) ### `src/routes/preview/+layout@.svelte` (68 LOC)
@@ -98,8 +98,8 @@ Live transcription overlay. Listens for `preview-listening`, `preview-cleanup`,
- Window creation: Rust opens four webviews and routes them to `/`, `/float`, `/viewer`, `/preview`. The same JS bundle loads in each. - Window creation: Rust opens four webviews and routes them to `/`, `/float`, `/viewer`, `/preview`. The same JS bundle loads in each.
- Cross window state: - Cross window state:
- `localStorage` carries `magnotia_settings` and the viewer handoff payload. Float window listens on the browser `storage` event to mirror settings. - `localStorage` carries `lumotia_settings` and the viewer handoff payload. Float window listens on the browser `storage` event to mirror settings.
- Tauri events carry preferences (`magnotia:preferences-changed`), tray driven navigation (`magnotia:open-wind-down`), and float window focus (`task-window-focus`). - Tauri events carry preferences (`lumotia:preferences-changed`), tray driven navigation (`lumotia:open-wind-down`), and float window focus (`task-window-focus`).
- Hotkey: only the main window registers, but every window's layout includes the migration `$effect`. The label guard at `+layout.svelte:115-121` prevents secondary windows from re registering. - Hotkey: only the main window registers, but every window's layout includes the migration `$effect`. The label guard at `+layout.svelte:115-121` prevents secondary windows from re registering.
- First run gating: only the main window evaluates and sets `page.current = "first-run"`. The float/viewer/preview windows skip the shell altogether. - First run gating: only the main window evaluates and sets `page.current = "first-run"`. The float/viewer/preview windows skip the shell altogether.

View File

@@ -2,24 +2,24 @@
name: Slice 02 — Tauri runtime name: Slice 02 — Tauri runtime
type: architecture-map-page type: architecture-map-page
slice: 02-tauri-runtime slice: 02-tauri-runtime
last_verified: 2026/05/09 last_verified: 2026/05/12
--- ---
# Slice 02 — Tauri runtime # Slice 02 — Tauri runtime
> **Where you are:** [Architecture map](../README.md) → Tauri runtime > **Where you are:** [Architecture map](../README.md) → Tauri runtime
**Plain English summary.** The Tauri runtime is the bridge between Magnotia's Svelte frontend and the Rust workspace crates. It owns app startup (database init, panic hook, plugin wiring, preferences injection), the system tray, the secondary windows (task float, transcript viewer, transcription preview), and the entire `#[tauri::command]` surface that the frontend invokes for audio capture, transcription, LLM cleanup, task and transcript CRUD, paste, TTS, hotkeys, diagnostics, and more. Everything that runs on the host process but is not pure crate logic lives here. **Plain English summary.** The Tauri runtime is the bridge between Lumotia's Svelte frontend and the Rust workspace crates. It owns app startup (database init, panic hook, plugin wiring, preferences injection), the system tray, the secondary windows (task float, transcript viewer, transcription preview), and the entire `#[tauri::command]` surface that the frontend invokes for audio capture, transcription, LLM cleanup, task and transcript CRUD, paste, TTS, hotkeys, diagnostics, and more. Everything that runs on the host process but is not pure crate logic lives here.
## At a glance ## At a glance
- Path: `src-tauri/` - Path: `src-tauri/`
- Total LOC (Rust + Cargo + JSON, excluding `gen/`): ~8,690. - Total LOC (Rust + Cargo + JSON, excluding `gen/`): ~8,690.
- Tauri version: `2` (see `src-tauri/Cargo.toml:44`). - Tauri version: `2` (see `src-tauri/Cargo.toml:44`).
- Bundle identifier: `uk.co.corbel.magnotia` (`src-tauri/tauri.conf.json:5`). - Bundle identifier: `uk.co.corbel.lumotia` (`src-tauri/tauri.conf.json:5`).
- Plugins (always-on): `tauri-plugin-opener`, `tauri-plugin-dialog`, `tauri-plugin-notification`. - Plugins (always-on): `tauri-plugin-opener`, `tauri-plugin-dialog`, `tauri-plugin-notification`.
- Plugins (desktop-only, gated `cfg(not(target_os = "android"))`): `tauri-plugin-global-shortcut`, `tauri-plugin-autostart` (LaunchAgent), `tauri-plugin-window-state`. The `tray-icon` Tauri feature is also desktop-only. - Plugins (desktop-only, gated `cfg(not(target_os = "android"))`): `tauri-plugin-global-shortcut`, `tauri-plugin-autostart` (LaunchAgent), `tauri-plugin-window-state`. The `tray-icon` Tauri feature is also desktop-only.
- Workspace crates pulled in: `magnotia-core`, `magnotia-audio`, `magnotia-transcription` (default-features off, `whisper` re-enabled here), `magnotia-ai-formatting`, `magnotia-storage`, `magnotia-cloud-providers`, `magnotia-hotkey`, `magnotia-llm`. - Workspace crates pulled in: `lumotia-core`, `lumotia-audio`, `lumotia-transcription` (default-features off, `whisper` re-enabled here), `lumotia-ai-formatting`, `lumotia-storage`, `lumotia-cloud-providers`, `lumotia-hotkey`, `lumotia-llm`.
- Tauri commands exposed via `invoke_handler` in `src-tauri/src/lib.rs:321`: 71 commands. - Tauri commands exposed via `invoke_handler` in `src-tauri/src/lib.rs:321`: 71 commands.
- Capability files: `src-tauri/capabilities/main.json` (main window) and `src-tauri/capabilities/secondary-windows.json` (task float, transcript viewer, transcription preview). - Capability files: `src-tauri/capabilities/main.json` (main window) and `src-tauri/capabilities/secondary-windows.json` (task float, transcript viewer, transcription preview).
- Command modules: 22 `#[tauri::command]` modules plus 3 utility modules (`mod.rs`, `power.rs`, `security.rs`). - Command modules: 22 `#[tauri::command]` modules plus 3 utility modules (`mod.rs`, `power.rs`, `security.rs`).
@@ -29,7 +29,7 @@ last_verified: 2026/05/09
App boot, config, tests: App boot, config, tests:
- [App lifecycle](app-lifecycle.md). `src-tauri/src/main.rs` and `src-tauri/src/lib.rs`. Run entry, AppState construction, plugin wiring, preferences injection, X11-on-Wayland workaround, panic hook, error-log pruning, command registration. - [App lifecycle](app-lifecycle.md). `src-tauri/src/main.rs` and `src-tauri/src/lib.rs`. Run entry, AppState construction, plugin wiring, preferences injection, Linux launcher-env warning, panic hook, error-log pruning, command registration.
- [System tray](system-tray.md). Desktop-only tray icon and menu (`src-tauri/src/tray.rs`). - [System tray](system-tray.md). Desktop-only tray icon and menu (`src-tauri/src/tray.rs`).
- [Tauri config](tauri-config.md). `tauri.conf.json` plus the Linux native-decorations overlay; CSP, window defaults, bundle settings. - [Tauri config](tauri-config.md). `tauri.conf.json` plus the Linux native-decorations overlay; CSP, window defaults, bundle settings.
- [Capabilities and ACL](capabilities-and-acl.md). The two ACL files in `capabilities/`, what each scopes, and the Phase 9 high-risk-permission firewall. - [Capabilities and ACL](capabilities-and-acl.md). The two ACL files in `capabilities/`, what each scopes, and the Phase 9 high-risk-permission firewall.
@@ -47,9 +47,9 @@ Individual command pages (linked from the commands index):
## How this slice connects to others ## How this slice connects to others
- **Frontend (slice 01).** Every `#[tauri::command]` is invoked from Svelte via `@tauri-apps/api/core` `invoke()`. Events emitted via `app.emit(...)` are consumed via `listen()` in the frontend. Live transcription uses typed `tauri::ipc::Channel` instead of plain events; the channel pair is created on the JS side and passed in as command args. - **Frontend (slice 01).** Every `#[tauri::command]` is invoked from Svelte via `@tauri-apps/api/core` `invoke()`. Events emitted via `app.emit(...)` are consumed via `listen()` in the frontend. Live transcription uses typed `tauri::ipc::Channel` instead of plain events; the channel pair is created on the JS side and passed in as command args.
- **Audio + transcription (slice 03).** `commands::audio` calls `magnotia_audio::{MicrophoneCapture, WavWriter, decode_audio_file_limited, resample_to_16khz, probe_audio_duration_secs}`. `commands::transcription` and `commands::live` call `magnotia_transcription::LocalEngine` plus `magnotia_audio::StreamingResampler`. `commands::models` calls `magnotia_transcription::{model_manager, load_whisper, load_parakeet}`. - **Audio + transcription (slice 03).** `commands::audio` calls `lumotia_audio::{MicrophoneCapture, WavWriter, decode_audio_file_limited, resample_to_16khz, probe_audio_duration_secs}`. `commands::transcription` and `commands::live` call `lumotia_transcription::LocalEngine` plus `lumotia_audio::StreamingResampler`. `commands::models` calls `lumotia_transcription::{model_manager, load_whisper, load_parakeet}`.
- **LLM + formatting + MCP (slice 04).** `commands::llm` calls `magnotia_llm::{LlmEngine, model_manager, ContentTags, LlmModelId}` and `magnotia_ai_formatting::{llm_cleanup_text, LlmPromptPreset}`. `commands::tasks` calls `magnotia_llm::prompts::FeedbackExample` and the engine's `decompose_task_with_feedback` / `extract_tasks_with_feedback` / `extract_content_tags`. `commands::transcription` and `commands::live` call `magnotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}`. `commands::profiles` calls `magnotia_ai_formatting::extract_corrections` for auto-learned vocabulary. - **LLM + formatting + MCP (slice 04).** `commands::llm` calls `lumotia_llm::{LlmEngine, model_manager, ContentTags, LlmModelId}` and `lumotia_ai_formatting::{llm_cleanup_text, LlmPromptPreset}`. `commands::tasks` calls `lumotia_llm::prompts::FeedbackExample` and the engine's `decompose_task_with_feedback` / `extract_tasks_with_feedback` / `extract_content_tags`. `commands::transcription` and `commands::live` call `lumotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}`. `commands::profiles` calls `lumotia_ai_formatting::extract_corrections` for auto-learned vocabulary.
- **Core + storage + hotkey + build (slice 05).** Everything DB-touching goes through `magnotia_storage` (`init`, `database_path`, `get_setting`, `set_setting`, `prune_error_log`, the full set of CRUD helpers, `app_data_dir`, `crashes_dir`, `logs_dir`, `list_recent_errors`, `log_error`). `commands::hardware` and `commands::models` call `magnotia_core::{hardware, model_registry, recommendation, types, constants}`. `commands::meeting` calls `magnotia_core::process_watch`. `commands::hotkey` is a thin Tauri wrapper around `magnotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent}`. `build.rs` is the build-system half of the slice (CSP regression guard plus ggml multi-definition link arg). - **Core + storage + hotkey + build (slice 05).** Everything DB-touching goes through `lumotia_storage` (`init`, `database_path`, `get_setting`, `set_setting`, `prune_error_log`, the full set of CRUD helpers, `app_data_dir`, `crashes_dir`, `logs_dir`, `list_recent_errors`, `log_error`). `commands::hardware` and `commands::models` call `lumotia_core::{hardware, model_registry, recommendation, types, constants}`. `commands::meeting` calls `lumotia_core::process_watch`. `commands::hotkey` is a thin Tauri wrapper around `lumotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent}`. `build.rs` is the build-system half of the slice (CSP regression guard plus ggml multi-definition link arg).
## Open questions / debt ## Open questions / debt
@@ -57,7 +57,7 @@ Individual command pages (linked from the commands index):
- `commands::update::install_update` returns a hard-coded "Updates are disabled until release signing is configured." (`src-tauri/src/commands/update.rs:15`). The integration test `updater_is_signed_or_absent` (`src-tauri/tests/config_hardening.rs:35`) only asserts that *if* an updater config ships, it carries a non-empty pubkey. There is currently no updater config in `tauri.conf.json`, so the test is an empty no-op. - `commands::update::install_update` returns a hard-coded "Updates are disabled until release signing is configured." (`src-tauri/src/commands/update.rs:15`). The integration test `updater_is_signed_or_absent` (`src-tauri/tests/config_hardening.rs:35`) only asserts that *if* an updater config ships, it carries a non-empty pubkey. There is currently no updater config in `tauri.conf.json`, so the test is an empty no-op.
- `commands::power::PowerAssertion` is a no-op on Linux and Windows (`src-tauri/src/commands/power.rs:90`). Only macOS has a real implementation via `objc2`. The doc comment promises `SetThreadExecutionState` (Windows) and logind inhibitors (Linux), but neither is wired up. Symptom: long live-dictation sessions on Linux can be idled by the compositor. - `commands::power::PowerAssertion` is a no-op on Linux and Windows (`src-tauri/src/commands/power.rs:90`). Only macOS has a real implementation via `objc2`. The doc comment promises `SetThreadExecutionState` (Windows) and logind inhibitors (Linux), but neither is wired up. Symptom: long live-dictation sessions on Linux can be idled by the compositor.
- `src-tauri/.cargo/config.toml` hard-codes `LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin"` (Windows). It is harmless on non-Windows hosts (env var is just unused) but it is a foot-gun if someone moves Clang elsewhere on Windows. See [Cargo and features](cargo-and-features.md). - `src-tauri/.cargo/config.toml` hard-codes `LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin"` (Windows). It is harmless on non-Windows hosts (env var is just unused) but it is a foot-gun if someone moves Clang elsewhere on Windows. See [Cargo and features](cargo-and-features.md).
- The Linux Wayland workaround in `lib.rs` (`ensure_x11_on_wayland`) sets env vars before any threads spawn. This is the right pattern, but it ships even when the user is on a working DMA-BUF stack. The override knob is `WEBKIT_DISABLE_DMABUF_RENDERER=0` set by the user; documented in the function header but not surfaced anywhere user-visible. - The Linux rendering workaround in `lib.rs` (`warn_if_x11_env_unset_on_wayland`) now warns when the launcher has not set the expected env vars; it no longer mutates the process environment. In development, `run.sh` / `npm run dev:tauri` owns the defaults (`WEBKIT_DISABLE_DMABUF_RENDERER=1` on Linux; `GDK_BACKEND=x11` and `WINIT_UNIX_BACKEND=x11` on Wayland). The user opt-out remains `WEBKIT_DISABLE_DMABUF_RENDERER=0`, set before launching.
- `commands::diagnostics::install_panic_hook` writes a "minimal text dump" without backtraces unless the user has `RUST_BACKTRACE=1` set (`src-tauri/src/commands/diagnostics.rs:42`). The packaged binary does not set it, so production crash dumps will lack stack traces. Document or default-enable. - `commands::diagnostics::install_panic_hook` writes a "minimal text dump" without backtraces unless the user has `RUST_BACKTRACE=1` set (`src-tauri/src/commands/diagnostics.rs:42`). The packaged binary does not set it, so production crash dumps will lack stack traces. Document or default-enable.
- `commands::audio::list_audio_devices` is gated `ensure_main_window` but the `secondary-windows` capability does not invoke it, which is correct. The `clipboard::copy_to_clipboard` command, by contrast, has no main-window guard and any window with the `core:default` permission can call it; intentional, but worth flagging. - `commands::audio::list_audio_devices` is gated `ensure_main_window` but the `secondary-windows` capability does not invoke it, which is correct. The `clipboard::copy_to_clipboard` command, by contrast, has no main-window guard and any window with the `core:default` permission can call it; intentional, but worth flagging.

View File

@@ -2,29 +2,29 @@
name: App lifecycle name: App lifecycle
type: architecture-map-page type: architecture-map-page
slice: 02-tauri-runtime slice: 02-tauri-runtime
last_verified: 2026/05/09 last_verified: 2026/05/12
--- ---
# App lifecycle # App lifecycle
> **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → App lifecycle > **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → App lifecycle
**Plain English summary.** This is the entry point. `main.rs` calls `magnotia_lib::run()` and `lib.rs::run` does everything that has to happen before the user sees a window: sets a Linux Wayland workaround, installs the Rust panic hook, registers Tauri plugins, opens the SQLite database, prunes the error log, builds a JS preferences-injection script, configures the WebKit media-permission grant on Linux, wires close-to-tray, populates `AppState` and the per-domain managed states, emits any runtime warnings, sets up the system tray, and finally registers all 71 Tauri commands. **Plain English summary.** This is the entry point. `main.rs` calls `lumotia_lib::run()` and `lib.rs::run` does everything that has to happen before the user sees a window: initialises tracing, checks the Linux launcher env-var contract, installs the Rust panic hook, registers Tauri plugins, opens the SQLite database, prunes the error log, builds a JS preferences-injection script, configures the WebKit media-permission grant on Linux, wires close-to-tray, populates `AppState` and the per-domain managed states, emits any runtime warnings, sets up the system tray, and finally registers all 71 Tauri commands.
## At a glance ## At a glance
- Path: `src-tauri/src/main.rs`, `src-tauri/src/lib.rs`. - Path: `src-tauri/src/main.rs`, `src-tauri/src/lib.rs`.
- LOC: 5 (main) + 448 (lib). - LOC: 5 (main) + 491 (lib).
- Tauri commands exposed directly here: `save_preferences` (string preferences -> SQLite settings table). All other commands live under `commands::*` and are registered via `tauri::generate_handler!`. - Tauri commands exposed directly here: `save_preferences` (string preferences -> SQLite settings table). All other commands live under `commands::*` and are registered via `tauri::generate_handler!`.
- Events emitted directly here: none (runtime warnings are emitted by `commands::models::emit_runtime_warnings`, called from setup). - Events emitted directly here: none (runtime warnings are emitted by `commands::models::emit_runtime_warnings`, called from setup).
- Depends on: `tauri`, `sqlx::SqlitePool`, `magnotia_core::types::EngineName`, `magnotia_llm::LlmEngine`, `magnotia_storage::{init, database_path, get_setting, set_setting, prune_error_log}`, `magnotia_transcription::LocalEngine`, plus the `commands::*` and `tray` modules. - Depends on: `tauri`, `sqlx::SqlitePool`, `lumotia_core::types::EngineName`, `lumotia_llm::LlmEngine`, `lumotia_storage::{init, database_path, get_setting, set_setting, prune_error_log}`, `lumotia_transcription::LocalEngine`, plus the `commands::*` and `tray` modules.
- Called from frontend at: every `invoke()` site in slice 01 lands in the handler list registered here. - Called from frontend at: every `invoke()` site in slice 01 lands in the handler list registered here.
## What's in here ## What's in here
### `main.rs` ### `main.rs`
Single function. Sets `windows_subsystem = "windows"` for release builds (no console window) and calls `magnotia_lib::run()`. (`src-tauri/src/main.rs:1`). Single function. Sets `windows_subsystem = "windows"` for release builds (no console window) and calls `lumotia_lib::run()`. (`src-tauri/src/main.rs:1`).
### `lib.rs` ### `lib.rs`
@@ -45,18 +45,20 @@ Types managed in Tauri state:
Functions: Functions:
- `build_preferences_script(prefs_json: Option<String>) -> String` (`src-tauri/src/lib.rs:38`). Builds an IIFE that reads saved preferences (theme, zone, accessibility settings: font family, font size, letter spacing, line height, transcript size, bionic reading, reduce motion) and applies them to `<html>` before the rest of the document loads. Embeds the JSON via `serde_json::to_string` to keep it safe. - `build_preferences_script(prefs_json: Option<String>) -> String` (`src-tauri/src/lib.rs:38`). Builds an IIFE that reads saved preferences (theme, zone, accessibility settings: font family, font size, letter spacing, line height, transcript size, bionic reading, reduce motion) and applies them to `<html>` before the rest of the document loads. Embeds the JSON via `serde_json::to_string` to keep it safe.
- `save_preferences(state, preferences) -> Result<(), String>` (`src-tauri/src/lib.rs:73`). The single command in `lib.rs`. Persists the preferences blob to the SQLite settings table under key `magnotia_preferences`. - `save_preferences(state, preferences) -> Result<(), String>` (`src-tauri/src/lib.rs:73`). The single command in `lib.rs`. Persists the preferences blob to the SQLite settings table under key `lumotia_preferences`.
- `ensure_x11_on_wayland()` (`src-tauri/src/lib.rs:99`, Linux only). Sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` unconditionally on Linux (iGPU idle-cost workaround), and additionally sets `GDK_BACKEND=x11` plus `WINIT_UNIX_BACKEND=x11` when `XDG_SESSION_TYPE=wayland`. Idempotent: if a value is already set, the function leaves it alone. Must run before any threads spawn — uses `unsafe { std::env::set_var(...) }`. - `init_tracing()` (Linux/macOS/Windows). Initialises the process-wide tracing subscriber once, honours `RUST_LOG`, and writes structured startup/runtime logs to stderr.
- `run()` (`src-tauri/src/lib.rs:135`). The Tauri builder pipeline. - `warn_if_x11_env_unset_on_wayland()` (Linux only). Emits a `lumotia_startup` warning when the launcher has not pre-set `WEBKIT_DISABLE_DMABUF_RENDERER` (always expected on Linux), or `GDK_BACKEND=x11` / `WINIT_UNIX_BACKEND=x11` when `XDG_SESSION_TYPE=wayland`. It does not mutate the process environment; `run.sh` owns the dev-time contract and package wrappers/.desktop files must own the distribution-time contract.
- `run()`. The Tauri builder pipeline.
### `run()` step-by-step ### `run()` step-by-step
1. **Linux env-var prelude.** Calls `ensure_x11_on_wayland()` on Linux (`src-tauri/src/lib.rs:137`). 1. **Tracing init.** Calls `init_tracing()` before any startup warnings/logs are emitted.
2. **Panic hook.** Calls `commands::diagnostics::install_panic_hook()` to dump panic info to `crashes_dir()` (`src-tauri/src/lib.rs:141`). 2. **Linux launcher contract check.** Calls `warn_if_x11_env_unset_on_wayland()` on Linux. Missing env vars produce warnings only; runtime env-var mutation was removed because Rust 2024 treats environment mutation in multi-threaded programs as unsafe.
3. **Plugin wiring (always-on).** `tauri_plugin_opener`, `tauri_plugin_dialog`, `tauri_plugin_notification` (`src-tauri/src/lib.rs:144`). 3. **Panic hook.** Calls `commands::diagnostics::install_panic_hook()` to dump panic info to `crashes_dir()`.
4. **Plugin wiring (desktop-only).** `tauri_plugin_global_shortcut`, `tauri_plugin_autostart` (LaunchAgent on macOS), `tauri_plugin_window_state` (`src-tauri/src/lib.rs:158`). 4. **Plugin wiring (always-on).** `tauri_plugin_opener`, `tauri_plugin_dialog`, `tauri_plugin_notification` (`src-tauri/src/lib.rs:144`).
5. **Setup hook.** This is where the bulk of startup work lives: 5. **Plugin wiring (desktop-only).** `tauri_plugin_global_shortcut`, `tauri_plugin_autostart` (LaunchAgent on macOS), `tauri_plugin_window_state` (`src-tauri/src/lib.rs:158`).
- Initialise SQLite via `magnotia_storage::init(&database_path()).await` using `tauri::async_runtime::block_on` (`src-tauri/src/lib.rs:180`). The `Instant::now()` timing is logged. 6. **Setup hook.** This is where the bulk of startup work lives:
- Initialise SQLite via `lumotia_storage::init(&database_path()).await` using `tauri::async_runtime::block_on` (`src-tauri/src/lib.rs:180`). The `Instant::now()` timing is logged.
- Prune `error_log` rows older than 90 days (`src-tauri/src/lib.rs:189`). Best-effort: a failure logs but does not block startup. - Prune `error_log` rows older than 90 days (`src-tauri/src/lib.rs:189`). Best-effort: a failure logs but does not block startup.
- Load saved preferences from the settings table; build the JS injection script (`src-tauri/src/lib.rs:204`). - Load saved preferences from the settings table; build the JS injection script (`src-tauri/src/lib.rs:204`).
- Apply the injection script to the main window via `WebviewWindow.eval()` (`src-tauri/src/lib.rs:215`). - Apply the injection script to the main window via `WebviewWindow.eval()` (`src-tauri/src/lib.rs:215`).
@@ -66,8 +68,8 @@ Functions:
- Build the `AppState` itself: fresh `LocalEngine`s for whisper and parakeet, the open `SqlitePool`, a fresh `LlmEngine` (`src-tauri/src/lib.rs:302`). - Build the `AppState` itself: fresh `LocalEngine`s for whisper and parakeet, the open `SqlitePool`, a fresh `LlmEngine` (`src-tauri/src/lib.rs:302`).
- Emit runtime warnings (CPU baseline, Vulkan loader) via `commands::models::emit_runtime_warnings` (`src-tauri/src/lib.rs:312`). - Emit runtime warnings (CPU baseline, Vulkan loader) via `commands::models::emit_runtime_warnings` (`src-tauri/src/lib.rs:312`).
- Setup the system tray on desktop (`src-tauri/src/lib.rs:314`). - Setup the system tray on desktop (`src-tauri/src/lib.rs:314`).
6. **Command registration.** `tauri::generate_handler![...]` lists 71 commands (`src-tauri/src/lib.rs:321`). The order in the macro is grouped by domain (preferences, models, LLM, transcription, audio, tasks, feedback, TTS, rituals, nudges, intentions, profiles, transcripts, diagnostics, live, windows, clipboard, fs, paste, meeting, hardware, hotkey, updater). 7. **Command registration.** `tauri::generate_handler![...]` lists 71 commands (`src-tauri/src/lib.rs:321`). The order in the macro is grouped by domain (preferences, models, LLM, transcription, audio, tasks, feedback, TTS, rituals, nudges, intentions, profiles, transcripts, diagnostics, live, windows, clipboard, fs, paste, meeting, hardware, hotkey, updater).
7. **Run.** `.run(tauri::generate_context!())` blocks the main thread until the app exits. Panics are wrapped with `expect("error while running Magnotia")`. 8. **Run.** `.run(tauri::generate_context!())` blocks the main thread until the app exits. Panics are wrapped with `expect("error while running Lumotia")`.
## Data flow ## Data flow
@@ -79,8 +81,8 @@ Functions:
## Watch-outs ## Watch-outs
- `tauri::async_runtime::block_on` inside `setup` blocks startup. The DB init and prefs read are explicitly timed and logged so regressions show up. Adding more synchronous async work here directly pushes the time-to-first-paint up. - `tauri::async_runtime::block_on` inside `setup` blocks startup. The DB init and prefs read are explicitly timed and logged so regressions show up. Adding more synchronous async work here directly pushes the time-to-first-paint up.
- The Linux media-permission wire-up is non-fatal: if `with_webview` fails the app still boots, but `getUserMedia` will be silently denied or fall back to a prompt the user cannot answer (no UI). The error path logs `[startup] failed to configure webview media permissions: ...` to stderr. - The Linux media-permission wire-up is non-fatal: if `with_webview` fails the app still boots, but `getUserMedia` will be silently denied or fall back to a prompt the user cannot answer (no UI). The error path logs a `lumotia_startup` warning.
- The `unsafe std::env::set_var` calls in `ensure_x11_on_wayland` are sound only because nothing else in the binary has spawned a thread yet at that point. Do not introduce another startup-time env mutation outside this function unless the same invariant is preserved. - Linux rendering env vars are a launcher contract, not a runtime mutation. In development, use `npm run dev:tauri` / `./run.sh`; packaged Linux builds need an equivalent wrapper or `.desktop` `Exec=env` policy. `WEBKIT_DISABLE_DMABUF_RENDERER=0` remains the user opt-out for the DMA-BUF workaround.
- Close-to-tray works only on desktop (the `cfg!(not(target_os = "android"))` block). On Android, closing the activity terminates the process, which is the expected platform behaviour. - Close-to-tray works only on desktop (the `cfg!(not(target_os = "android"))` block). On Android, closing the activity terminates the process, which is the expected platform behaviour.
- The `prewarm_default_model` call is *not* wired here. `commands::models::prewarm_default_model` exists, but `setup` does not invoke it. The frontend invokes the matching `prewarm_default_model_cmd` command after the main page mounts. If you ever want to shift pre-warm into setup, watch the spawn_blocking ordering against the engine `Arc` clones. - The `prewarm_default_model` call is *not* wired here. `commands::models::prewarm_default_model` exists, but `setup` does not invoke it. The frontend invokes the matching `prewarm_default_model_cmd` command after the main page mounts. If you ever want to shift pre-warm into setup, watch the spawn_blocking ordering against the engine `Arc` clones.

View File

@@ -9,7 +9,7 @@ last_verified: 2026/05/09
> **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → Capabilities and ACL > **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → Capabilities and ACL
**Plain English summary.** Tauri 2 ships a permission-and-capability ACL: every plugin and core API ships permissions, capabilities bind permissions to specific window labels. Magnotia ships two capability files. The main window gets the broad set (dialog, opener, autostart, global shortcuts, notifications, full window control). The three secondary windows (task float, transcript viewer, transcription preview) get a narrow set with no plugin permissions at all and no destructive window APIs. The split is the firewall that prevents a compromised secondary window from launching an installer or registering a global hotkey. **Plain English summary.** Tauri 2 ships a permission-and-capability ACL: every plugin and core API ships permissions, capabilities bind permissions to specific window labels. Lumotia ships two capability files. The main window gets the broad set (dialog, opener, autostart, global shortcuts, notifications, full window control). The three secondary windows (task float, transcript viewer, transcription preview) get a narrow set with no plugin permissions at all and no destructive window APIs. The split is the firewall that prevents a compromised secondary window from launching an installer or registering a global hotkey.
## At a glance ## At a glance
@@ -57,7 +57,7 @@ Notes:
- The window-control set is granular: there is no `core:window:default`, every action is opted in. `allow-start-dragging` is what the custom Titlebar uses to drag a frameless window. - The window-control set is granular: there is no `core:window:default`, every action is opted in. `allow-start-dragging` is what the custom Titlebar uses to drag a frameless window.
- `opener:default` lets the frontend open external URLs via `tauri-plugin-opener`. This is how the Settings → About links route out. - `opener:default` lets the frontend open external URLs via `tauri-plugin-opener`. This is how the Settings → About links route out.
- `dialog:default` enables the file/save dialogs used by the import-audio and save-diagnostic-report flows. - `dialog:default` enables the file/save dialogs used by the import-audio and save-diagnostic-report flows.
- `global-shortcut:allow-register` and `allow-unregister` let the main window manage the dictation hotkey via the cross-platform plugin path. On Linux Magnotia uses the bespoke evdev backend (see `commands::hotkey`), but Settings still talks to the global-shortcut plugin so the same UI works on macOS / Windows. - `global-shortcut:allow-register` and `allow-unregister` let the main window manage the dictation hotkey via the cross-platform plugin path. On Linux Lumotia uses the bespoke evdev backend (see `commands::hotkey`), but Settings still talks to the global-shortcut plugin so the same UI works on macOS / Windows.
- `autostart:*` lets Settings toggle login-time autostart. - `autostart:*` lets Settings toggle login-time autostart.
- `notification:*` is what the Phase 6 nudge bus uses; the Rust-side `commands::nudges::deliver_nudge` adds the main-window-only firewall. - `notification:*` is what the Phase 6 nudge bus uses; the Rust-side `commands::nudges::deliver_nudge` adds the main-window-only firewall.

View File

@@ -23,12 +23,12 @@ last_verified: 2026/05/09
### `Cargo.toml` ### `Cargo.toml`
Package metadata: `name = "magnotia"`, `version = "0.1.0"`, `description = "Magnotia — Think out loud"`, `authors = ["CORBEL Ltd"]`, `edition = "2021"`. Package metadata: `name = "lumotia"`, `version = "0.1.0"`, `description = "Lumotia — Think out loud"`, `authors = ["CORBEL Ltd"]`, `edition = "2021"`.
Lib stanza (`src-tauri/Cargo.toml:8`): Lib stanza (`src-tauri/Cargo.toml:8`):
``` ```
name = "magnotia_lib" name = "lumotia_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
``` ```
@@ -38,10 +38,10 @@ crate-type = ["staticlib", "cdylib", "rlib"]
``` ```
default = ["whisper"] default = ["whisper"]
whisper = ["magnotia-transcription/whisper"] whisper = ["lumotia-transcription/whisper"]
``` ```
The `whisper` feature transitively enables `magnotia-transcription/whisper`. The crate-level `default-features = false` on `magnotia-transcription` (`src-tauri/Cargo.toml:33`) means a `--no-default-features` workspace build drops `whisper-rs-sys` entirely; Parakeet still works. `commands::models::load_model_from_disk` returns a clear runtime error for `Engine::Whisper` when the feature is off. The `whisper` feature transitively enables `lumotia-transcription/whisper`. The crate-level `default-features = false` on `lumotia-transcription` (`src-tauri/Cargo.toml:33`) means a `--no-default-features` workspace build drops `whisper-rs-sys` entirely; Parakeet still works. `commands::models::load_model_from_disk` returns a clear runtime error for `Engine::Whisper` when the feature is off.
#### `[build-dependencies]` #### `[build-dependencies]`
@@ -50,14 +50,14 @@ The `whisper` feature transitively enables `magnotia-transcription/whisper`. The
#### `[dependencies]` — workspace crates #### `[dependencies]` — workspace crates
``` ```
magnotia-core lumotia-core
magnotia-audio lumotia-audio
magnotia-transcription { default-features = false } lumotia-transcription { default-features = false }
magnotia-ai-formatting lumotia-ai-formatting
magnotia-storage lumotia-storage
magnotia-cloud-providers lumotia-cloud-providers
magnotia-hotkey lumotia-hotkey
magnotia-llm lumotia-llm
``` ```
The `cloud-providers` crate is referenced here even though no command file currently imports it. Likely reserved for the Phase-N upload flow that the diagnostic-report bundler hints at. The `cloud-providers` crate is referenced here even though no command file currently imports it. Likely reserved for the Phase-N upload flow that the diagnostic-report bundler hints at.
@@ -84,7 +84,7 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
``` ```
The `sqlx` block has `default-features = false` and only opts into `runtime-tokio` and `sqlite`. `magnotia-storage` already pulls the macros / migrate / any / json features via its own re-export, so duplicating them here would just bloat compile time. `sqlx` is named directly because `AppState` types it as `SqlitePool`; naming a transitive dep type still requires the dep be listed. The `sqlx` block has `default-features = false` and only opts into `runtime-tokio` and `sqlite`. `lumotia-storage` already pulls the macros / migrate / any / json features via its own re-export, so duplicating them here would just bloat compile time. `sqlx` is named directly because `AppState` types it as `SqlitePool`; naming a transitive dep type still requires the dep be listed.
#### `[dev-dependencies]` #### `[dev-dependencies]`

View File

@@ -21,7 +21,7 @@ last_verified: 2026/05/09
- `stop_native_capture(window, state) -> Result<Vec<f32>, String>` — main-window only. Awaits the worker join barrier (RB-06) and returns the accumulated samples. - `stop_native_capture(window, state) -> Result<Vec<f32>, String>` — main-window only. Awaits the worker join barrier (RB-06) and returns the accumulated samples.
- `save_audio(window, app, samples, output_folder: Option<String>) -> Result<String, String>` — main-window only. Writes a fresh WAV to `app_local_data_dir/recordings/` (or a user-chosen folder) and returns its absolute path. - `save_audio(window, app, samples, output_folder: Option<String>) -> Result<String, String>` — main-window only. Writes a fresh WAV to `app_local_data_dir/recordings/` (or a user-chosen folder) and returns its absolute path.
- Events emitted: `native-pcm` (payload `{ samples: Vec<f32> }`, ~0.5 s of 16 kHz mono per emit) — `src-tauri/src/commands/audio.rs:249` and `:274`. - Events emitted: `native-pcm` (payload `{ samples: Vec<f32> }`, ~0.5 s of 16 kHz mono per emit) — `src-tauri/src/commands/audio.rs:249` and `:274`.
- Depends on: `magnotia_audio::{DeviceInfo, MicrophoneCapture, WavWriter, write_wav}`, `magnotia_core::types::AudioSamples`, `magnotia_core::constants::WHISPER_SAMPLE_RATE`. Plus `tokio::{mpsc, Mutex, JoinHandle, spawn_blocking, sleep}` and `std::sync::{Arc, Mutex, atomic::AtomicBool}`. - Depends on: `lumotia_audio::{DeviceInfo, MicrophoneCapture, WavWriter, write_wav}`, `lumotia_core::types::AudioSamples`, `lumotia_core::constants::WHISPER_SAMPLE_RATE`. Plus `tokio::{mpsc, Mutex, JoinHandle, spawn_blocking, sleep}` and `std::sync::{Arc, Mutex, atomic::AtomicBool}`.
- Called from frontend at: dictation page (start / stop), Settings → Audio (device list), file-import flow (save_audio). - Called from frontend at: dictation page (start / stop), Settings → Audio (device list), file-import flow (save_audio).
## What's in here ## What's in here
@@ -75,11 +75,11 @@ Public helper used by `commands::live::start_live_transcription_session`. Resolv
### `recording_filename` (`src-tauri/src/commands/audio.rs:365`) ### `recording_filename` (`src-tauri/src/commands/audio.rs:365`)
Deterministic filename: `magnotia-<unix_secs>-<nanos:09>-<counter:04>.wav`. The counter is a process-lifetime `AtomicU64` (`RECORDING_COUNTER`) bumped on each call. The combination defeats wall-clock collisions both across launches (secs change) and within the same nanosecond (counter changes). Deterministic filename: `lumotia-<unix_secs>-<nanos:09>-<counter:04>.wav`. The counter is a process-lifetime `AtomicU64` (`RECORDING_COUNTER`) bumped on each call. The combination defeats wall-clock collisions both across launches (secs change) and within the same nanosecond (counter changes).
### `persist_audio_samples` and `save_audio` (`src-tauri/src/commands/audio.rs:512`, `:531`) ### `persist_audio_samples` and `save_audio` (`src-tauri/src/commands/audio.rs:512`, `:531`)
`save_audio` is the public command. Calls `persist_audio_samples`, which resolves the recording path, then calls `magnotia_audio::write_wav` inside `spawn_blocking`. Returns the absolute path as a string. `save_audio` is the public command. Calls `persist_audio_samples`, which resolves the recording path, then calls `lumotia_audio::write_wav` inside `spawn_blocking`. Returns the absolute path as a string.
## Data flow ## Data flow
@@ -90,7 +90,7 @@ Deterministic filename: `magnotia-<unix_secs>-<nanos:09>-<counter:04>.wav`. The
## Watch-outs ## Watch-outs
- The in-memory buffer is capped at 10 minutes. Anything longer relies on the temp WAV; the frontend cannot just `stop_native_capture` and treat the returned vec as ground truth for long sessions. Today's UI assumes short captures, but the cap should be surfaced if you build a long-form recorder. - The in-memory buffer is capped at 10 minutes. Anything longer relies on the temp WAV; the frontend cannot just `stop_native_capture` and treat the returned vec as ground truth for long sessions. Today's UI assumes short captures, but the cap should be surfaced if you build a long-form recorder.
- The downsampler is naive decimation. Acceptable for speech but not for music. If you ever extend Magnotia to general audio, swap in `magnotia_audio::StreamingResampler` (which is what `commands::live` uses). - The downsampler is naive decimation. Acceptable for speech but not for music. If you ever extend Lumotia to general audio, swap in `lumotia_audio::StreamingResampler` (which is what `commands::live` uses).
- `start_native_capture` does *not* engage `PowerAssertion` (App Nap pinning). `commands::live::run_live_session` does. If you build a long-form non-live recorder on top of this, add the assertion. - `start_native_capture` does *not* engage `PowerAssertion` (App Nap pinning). `commands::live::run_live_session` does. If you build a long-form non-live recorder on top of this, add the assertion.
- The worker emits `native-pcm` to the entire app handle (not a specific window). Every webview that listens will receive the chunks. If you ever route audio to a non-main window, double-check this is what you want. - The worker emits `native-pcm` to the entire app handle (not a specific window). Every webview that listens will receive the chunks. If you ever route audio to a non-main window, double-check this is what you want.
- `stop_worker_awaits_full_termination_no_writes_after_join` (`src-tauri/src/commands/audio.rs:454`) is the regression test for RB-06. Do not change `stop_worker` to a fire-and-forget pattern. - `stop_worker_awaits_full_termination_no_writes_after_join` (`src-tauri/src/commands/audio.rs:454`) is the regression test for RB-06. Do not change `stop_worker` to a fire-and-forget pattern.

View File

@@ -24,7 +24,7 @@ last_verified: 2026/05/09
- `get_os_info() -> OsInfo`. - `get_os_info() -> OsInfo`.
- Public Rust helper used by `lib.rs::run`: `install_panic_hook()`. - Public Rust helper used by `lib.rs::run`: `install_panic_hook()`.
- Events emitted: none. - Events emitted: none.
- Depends on: `magnotia_storage::{app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow}`, `commands::power::active_assertions_snapshot`, `commands::security::ensure_main_window`. Plus `std::fs`, `std::panic`, `std::time`. - Depends on: `lumotia_storage::{app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow}`, `commands::power::active_assertions_snapshot`, `commands::security::ensure_main_window`. Plus `std::fs`, `std::panic`, `std::time`.
- Called from frontend at: global `window.onerror` / `window.unhandledrejection` (`log_frontend_error`); Settings → About → Diagnostics (the bundle commands and `list_*` commands); top-of-app Cmd-vs-Ctrl labelling (`get_os_info`). - Called from frontend at: global `window.onerror` / `window.unhandledrejection` (`log_frontend_error`); Settings → About → Diagnostics (the bundle commands and `list_*` commands); top-of-app Cmd-vs-Ctrl labelling (`get_os_info`).
## What's in here ## What's in here
@@ -40,7 +40,7 @@ Creates `crashes_dir()` if missing. Replaces the default panic hook with one tha
### `log_frontend_error` (`:86`) ### `log_frontend_error` (`:86`)
Truncates the stack to 2,000 chars and calls `magnotia_storage::log_error` with context `"frontend"` (or the caller-supplied context), error code `"FRONTEND_ERROR"`, the message, and the stack as metadata. Truncates the stack to 2,000 chars and calls `lumotia_storage::log_error` with context `"frontend"` (or the caller-supplied context), error code `"FRONTEND_ERROR"`, the message, and the stack as metadata.
### `ErrorLogDto` (`:114`) and `list_recent_errors_command` (`:138`) ### `ErrorLogDto` (`:114`) and `list_recent_errors_command` (`:138`)
@@ -68,11 +68,11 @@ Builds a markdown document with sections:
3. Recent errors (50 rows, redacted). 3. Recent errors (50 rows, redacted).
4. Power assertions (snapshots from `commands::power::active_assertions_snapshot`). 4. Power assertions (snapshots from `commands::power::active_assertions_snapshot`).
5. Crash dumps (up to 5 most-recent, plus a count of older ones). 5. Crash dumps (up to 5 most-recent, plus a count of older ones).
6. Log tail (8 KB tail of `logs_dir/magnotia.log`, home-redacted). 6. Log tail (8 KB tail of `logs_dir/lumotia.log`, home-redacted).
### `save_diagnostic_report` (`:513`) ### `save_diagnostic_report` (`:513`)
Generates the report, then writes it to `app_data_dir/diagnostic-reports/magnotia-diagnostic-<unix_secs>.md`. Returns the absolute path. Generates the report, then writes it to `app_data_dir/diagnostic-reports/lumotia-diagnostic-<unix_secs>.md`. Returns the absolute path.
### `OsInfo` and `get_os_info` (`:449`, `:474`) ### `OsInfo` and `get_os_info` (`:449`, `:474`)
@@ -83,7 +83,7 @@ OS family label, arch, `uses_cmd` boolean (macOS only), `is_wayland` boolean (Li
``` ```
panic occurs -> install_panic_hook writes crashes_dir/<ts>.crash panic occurs -> install_panic_hook writes crashes_dir/<ts>.crash
window.onerror fires in JS -> invoke('log_frontend_error', context, message, stack) window.onerror fires in JS -> invoke('log_frontend_error', context, message, stack)
-> magnotia_storage::log_error -> lumotia_storage::log_error
-> error_log table -> error_log table
Settings -> About -> Diagnostics Settings -> About -> Diagnostics

View File

@@ -19,7 +19,7 @@ last_verified: 2026/05/09
- `record_feedback(state, input: RecordFeedbackInput) -> Result<i64, String>` — returns the new row id. - `record_feedback(state, input: RecordFeedbackInput) -> Result<i64, String>` — returns the new row id.
- `list_feedback_examples_cmd(state, target_type, limit, min_rating, profile_id) -> Result<Vec<FeedbackDto>, String>`. - `list_feedback_examples_cmd(state, target_type, limit, min_rating, profile_id) -> Result<Vec<FeedbackDto>, String>`.
- Events emitted: none. - Events emitted: none.
- Depends on: `magnotia_storage::{record_feedback, list_feedback_examples, FeedbackRow, FeedbackTargetType, RecordFeedbackParams}`. - Depends on: `lumotia_storage::{record_feedback, list_feedback_examples, FeedbackRow, FeedbackTargetType, RecordFeedbackParams}`.
- Called from frontend at: dictation result panel (thumb up/down + correction-text on cleanup); Tasks page (thumb on extracted tasks and decomposed microsteps). - Called from frontend at: dictation result panel (thumb up/down + correction-text on cleanup); Tasks page (thumb on extracted tasks and decomposed microsteps).
## What's in here ## What's in here
@@ -56,7 +56,7 @@ Clamps `limit` to `[1, 64]`, defaults 8. Clamps `min_rating` to `[-1, 1]`, defau
``` ```
dictation result thumbs-up -> invoke('record_feedback', { targetType: 'cleanup', rating: +1, originalText, correctedText, contextJson, profileId }) dictation result thumbs-up -> invoke('record_feedback', { targetType: 'cleanup', rating: +1, originalText, correctedText, contextJson, profileId })
-> magnotia_storage::record_feedback -> row id -> lumotia_storage::record_feedback -> row id
decomposition thumbs-down + correction -> record_feedback({ targetType: 'microstep', rating: 0, originalText, correctedText: "user's preferred wording", contextJson: {parent_text}, profileId }) decomposition thumbs-down + correction -> record_feedback({ targetType: 'microstep', rating: 0, originalText, correctedText: "user's preferred wording", contextJson: {parent_text}, profileId })

View File

@@ -9,7 +9,7 @@ last_verified: 2026/05/09
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Hotkey bridge > **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Hotkey bridge
**Plain English summary.** The Linux Wayland-compatible global hotkey backend. Tauri's `tauri-plugin-global-shortcut` works on macOS / Windows and on X11 Linux but fails silently on Wayland (the protocol forbids unprivileged keystroke grabs). Magnotia's bespoke evdev backend reads `/dev/input/event*` directly, parses key combos, and emits `magnotia:hotkey-pressed` / `magnotia:hotkey-released` events. Settings on Linux uses these commands instead of the global-shortcut plugin; everywhere else the plugin path is canonical. **Plain English summary.** The Linux Wayland-compatible global hotkey backend. Tauri's `tauri-plugin-global-shortcut` works on macOS / Windows and on X11 Linux but fails silently on Wayland (the protocol forbids unprivileged keystroke grabs). Lumotia's bespoke evdev backend reads `/dev/input/event*` directly, parses key combos, and emits `lumotia:hotkey-pressed` / `lumotia:hotkey-released` events. Settings on Linux uses these commands instead of the global-shortcut plugin; everywhere else the plugin path is canonical.
## At a glance ## At a glance
@@ -21,8 +21,8 @@ last_verified: 2026/05/09
- `start_evdev_hotkey(app, state, hotkey: String) -> Result<(), String>`. Parses the Tauri-style combo string, stops any existing listener, starts a new one, spawns a forwarder task that converts evdev events to Tauri events. - `start_evdev_hotkey(app, state, hotkey: String) -> Result<(), String>`. Parses the Tauri-style combo string, stops any existing listener, starts a new one, spawns a forwarder task that converts evdev events to Tauri events.
- `update_evdev_hotkey(state, hotkey: String) -> Result<(), String>`. Updates the combo on a running listener. - `update_evdev_hotkey(state, hotkey: String) -> Result<(), String>`. Updates the combo on a running listener.
- `stop_evdev_hotkey(state) -> Result<(), String>`. Stops cleanly. - `stop_evdev_hotkey(state) -> Result<(), String>`. Stops cleanly.
- Events emitted: `magnotia:hotkey-pressed` (no payload), `magnotia:hotkey-released` (no payload). Fired from the forwarder task in `start_evdev_hotkey` (`src-tauri/src/commands/hotkey.rs:67`, `:70`). - Events emitted: `lumotia:hotkey-pressed` (no payload), `lumotia:hotkey-released` (no payload). Fired from the forwarder task in `start_evdev_hotkey` (`src-tauri/src/commands/hotkey.rs:67`, `:70`).
- Depends on: `magnotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent, check_evdev_access}`. Plus `tokio::sync::{mpsc, Mutex}`. - Depends on: `lumotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent, check_evdev_access}`. Plus `tokio::sync::{mpsc, Mutex}`.
- Called from frontend at: Settings → Hotkey on Linux. Other platforms call the `tauri-plugin-global-shortcut` plugin's JS API directly. - Called from frontend at: Settings → Hotkey on Linux. Other platforms call the `tauri-plugin-global-shortcut` plugin's JS API directly.
## What's in here ## What's in here
@@ -37,7 +37,7 @@ Returns true if `WAYLAND_DISPLAY` is set or `XDG_SESSION_TYPE=wayland`. Used by
### `check_hotkey_access` (`:32`) ### `check_hotkey_access` (`:32`)
Defers to `magnotia_hotkey::check_evdev_access` — that helper checks the user can read `/dev/input/event*` (typically requires being in the `input` group, or a udev rule). On failure, returns the actionable error string. Defers to `lumotia_hotkey::check_evdev_access` — that helper checks the user can read `/dev/input/event*` (typically requires being in the `input` group, or a udev rule). On failure, returns the actionable error string.
### `start_evdev_hotkey` (`:41`) ### `start_evdev_hotkey` (`:41`)
@@ -45,7 +45,7 @@ Defers to `magnotia_hotkey::check_evdev_access` — that helper checks the user
2. Lock the listener mutex; if a listener is already running, stop it and await termination. 2. Lock the listener mutex; if a listener is already running, stop it and await termination.
3. Build a 64-deep `tokio::sync::mpsc` channel for `HotkeyEvent`s. 3. Build a 64-deep `tokio::sync::mpsc` channel for `HotkeyEvent`s.
4. Call `EvdevHotkeyListener::start(combo, event_tx)`, stash the listener. 4. Call `EvdevHotkeyListener::start(combo, event_tx)`, stash the listener.
5. Spawn a `tokio::spawn` forwarder that reads from the event_rx and emits `magnotia:hotkey-pressed` / `magnotia:hotkey-released` per event variant. 5. Spawn a `tokio::spawn` forwarder that reads from the event_rx and emits `lumotia:hotkey-pressed` / `lumotia:hotkey-released` per event variant.
### `update_evdev_hotkey` (`:81`) ### `update_evdev_hotkey` (`:81`)
@@ -65,7 +65,7 @@ Settings -> start_evdev_hotkey("Shift+Cmd+Space")
-> stop any prior listener -> stop any prior listener
-> EvdevHotkeyListener spawns its own thread reading /dev/input/event* -> EvdevHotkeyListener spawns its own thread reading /dev/input/event*
-> forwarder spawn: HotkeyEvent -> Tauri event -> forwarder spawn: HotkeyEvent -> Tauri event
frontend listens on 'magnotia:hotkey-pressed' / '...-released' and starts/stops dictation frontend listens on 'lumotia:hotkey-pressed' / '...-released' and starts/stops dictation
Settings -> update_evdev_hotkey("Ctrl+Alt+M") (when user changes binding) Settings -> update_evdev_hotkey("Ctrl+Alt+M") (when user changes binding)
Settings -> stop_evdev_hotkey() (when user disables) Settings -> stop_evdev_hotkey() (when user disables)
``` ```
@@ -73,8 +73,8 @@ Settings -> stop_evdev_hotkey() (when user disables)
## Watch-outs ## Watch-outs
- **No `ensure_main_window` guard.** Settings is in the main window. If you ever expose hotkey re-binding in a secondary window, add the guard. - **No `ensure_main_window` guard.** Settings is in the main window. If you ever expose hotkey re-binding in a secondary window, add the guard.
- **Linux only.** macOS and Windows code paths in the frontend talk to `tauri-plugin-global-shortcut` directly — no Rust commands needed because the plugin handles register / unregister via JS. This module compiles and runs on every desktop OS but only *works* on Linux because evdev is Linux-specific (the workspace crate `magnotia_hotkey` has the platform shim). - **Linux only.** macOS and Windows code paths in the frontend talk to `tauri-plugin-global-shortcut` directly — no Rust commands needed because the plugin handles register / unregister via JS. This module compiles and runs on every desktop OS but only *works* on Linux because evdev is Linux-specific (the workspace crate `lumotia_hotkey` has the platform shim).
- **`/dev/input/event*` access requires either:** (a) the user is in the `input` group, or (b) a udev rule grants Magnotia explicit access. The Settings UI walks the user through option (a) on first run; option (b) is documented in `docs/dev-setup.md` for power users. - **`/dev/input/event*` access requires either:** (a) the user is in the `input` group, or (b) a udev rule grants Lumotia explicit access. The Settings UI walks the user through option (a) on first run; option (b) is documented in `docs/dev-setup.md` for power users.
- **Channel size of 64** is enough for a key smash burst. If a worker stalls and the channel fills, evdev events would be dropped silently. Consider logging a warning when the channel is at capacity. - **Channel size of 64** is enough for a key smash burst. If a worker stalls and the channel fills, evdev events would be dropped silently. Consider logging a warning when the channel is at capacity.
- **The forwarder spawn is detached.** No way to stop the spawn task explicitly; it exits when `event_rx.recv().await` returns None (which happens when the listener is dropped). Acceptable. - **The forwarder spawn is detached.** No way to stop the spawn task explicitly; it exits when `event_rx.recv().await` returns None (which happens when the listener is dropped). Acceptable.
- **No power assertion.** The evdev listener thread is pinned by the kernel via the file handle; idle CPU is near zero. - **No power assertion.** The evdev listener thread is pinned by the kernel via the file handle; idle CPU is near zero.

View File

@@ -22,7 +22,7 @@ last_verified: 2026/05/09
- `mark_implementation_rule_fired(window, state, id, fired_key) -> Result<ImplementationRuleDto, String>`. - `mark_implementation_rule_fired(window, state, id, fired_key) -> Result<ImplementationRuleDto, String>`.
- `delete_implementation_rule(window, state, id) -> Result<(), String>`. - `delete_implementation_rule(window, state, id) -> Result<(), String>`.
- Events emitted: none. - Events emitted: none.
- Depends on: `magnotia_storage::{insert_implementation_rule, list_implementation_rules, set_implementation_rule_enabled, mark_implementation_rule_fired, delete_implementation_rule, get_task_by_id, ImplementationRuleRow}`, `uuid::Uuid`, `serde_json`. Plus `commands::security::ensure_main_window`. - Depends on: `lumotia_storage::{insert_implementation_rule, list_implementation_rules, set_implementation_rule_enabled, mark_implementation_rule_fired, delete_implementation_rule, get_task_by_id, ImplementationRuleRow}`, `uuid::Uuid`, `serde_json`. Plus `commands::security::ensure_main_window`.
- Called from frontend at: Settings → Implementation intentions (CRUD); the rule-runner module (`mark_implementation_rule_fired` after firing). - Called from frontend at: Settings → Implementation intentions (CRUD); the rule-runner module (`mark_implementation_rule_fired` after firing).
## What's in here ## What's in here
@@ -67,7 +67,7 @@ Settings -> create_implementation_rule(req)
-> validate_request (HH:MM if applicable, action shape, task existence for surface-task) -> validate_request (HH:MM if applicable, action shape, task existence for surface-task)
-> uuid::v4 for id -> uuid::v4 for id
-> serde_json serialise actions -> serde_json serialise actions
-> magnotia_storage::insert_implementation_rule -> lumotia_storage::insert_implementation_rule
-> list/return DTO -> list/return DTO
frontend rule-runner cron -> evaluates triggers in JS frontend rule-runner cron -> evaluates triggers in JS
@@ -78,7 +78,7 @@ frontend rule-runner cron -> evaluates triggers in JS
## Watch-outs ## Watch-outs
- **Action timer is hard-capped to 300 seconds.** The frontend has to surface this constraint. Future versions will need to widen the validator to accept arbitrary durations. - **Action timer is hard-capped to 300 seconds.** The frontend has to surface this constraint. Future versions will need to widen the validator to accept arbitrary durations.
- **Rule execution lives entirely in the frontend.** If the user closes Magnotia at 08:55 with a 09:00 rule, the rule does not fire. There is no daemon. This is documented in the module header. - **Rule execution lives entirely in the frontend.** If the user closes Lumotia at 08:55 with a 09:00 rule, the rule does not fire. There is no daemon. This is documented in the module header.
- **Validation is sync against the DB.** `validate_actions` calls `get_task_by_id` for every surface-task action. Multiple actions in one rule = multiple round-trips. Acceptable today; consider batching if a rule grows huge. - **Validation is sync against the DB.** `validate_actions` calls `get_task_by_id` for every surface-task action. Multiple actions in one rule = multiple round-trips. Acceptable today; consider batching if a rule grows huge.
- **`mark_implementation_rule_fired` requires a non-empty `fired_key`.** The frontend should always pass an ISO date for daily rules and a per-event key for task-completed rules. Empty key = command rejection. - **`mark_implementation_rule_fired` requires a non-empty `fired_key`.** The frontend should always pass an ISO date for daily rules and a per-event key for task-completed rules. Empty key = command rejection.
- **No `PowerAssertion`.** None of these actions run inference. No need. - **No `PowerAssertion`.** None of these actions run inference. No need.

View File

@@ -18,10 +18,10 @@ last_verified: 2026/05/09
- Tauri commands exposed: - Tauri commands exposed:
- `start_live_transcription_session(window, app, state, live_state, config: StartLiveTranscriptionConfig, result_channel: Channel<LiveResultMessage>, status_channel: Channel<LiveStatusMessage>) -> Result<StartLiveTranscriptionResponse, String>` — main-window only. - `start_live_transcription_session(window, app, state, live_state, config: StartLiveTranscriptionConfig, result_channel: Channel<LiveResultMessage>, status_channel: Channel<LiveStatusMessage>) -> Result<StartLiveTranscriptionResponse, String>` — main-window only.
- `stop_live_transcription_session(window, app, live_state, session_id: u64) -> Result<StopLiveTranscriptionResponse, String>` — main-window only. - `stop_live_transcription_session(window, app, live_state, session_id: u64) -> Result<StopLiveTranscriptionResponse, String>` — main-window only.
- Events emitted: NONE in the conventional `app.emit(...)` sense. This module uses Tauri 2's typed `tauri::ipc::Channel<T>` API instead. The frontend creates the channel pair on the JS side via `new Channel<T>()`, passes it as a command argument, and Magnotia sends typed messages on it from the worker. Two channels: - Events emitted: NONE in the conventional `app.emit(...)` sense. This module uses Tauri 2's typed `tauri::ipc::Channel<T>` API instead. The frontend creates the channel pair on the JS side via `new Channel<T>()`, passes it as a command argument, and Lumotia sends typed messages on it from the worker. Two channels:
- `Channel<LiveResultMessage>` — per-chunk transcription results (segments, language, duration, raw_text, inference_ms, chunk_id, chunk_start_secs). - `Channel<LiveResultMessage>` — per-chunk transcription results (segments, language, duration, raw_text, inference_ms, chunk_id, chunk_start_secs).
- `Channel<LiveStatusMessage>` — tagged enum: `Warning { message }`, `Overload { dropped_audio_ms, message }`, `Error { message }`, `Finished { audio_path, dropped_audio_ms }`. - `Channel<LiveStatusMessage>` — tagged enum: `Warning { message }`, `Overload { dropped_audio_ms, message }`, `Error { message }`, `Finished { audio_path, dropped_audio_ms }`.
- Depends on: `magnotia_audio::{AudioChunk, CaptureRuntimeError, MicrophoneCapture, StreamingResampler, WavWriter}`, `magnotia_core::constants::WHISPER_SAMPLE_RATE`, `magnotia_core::types::{AudioSamples, Segment, TranscriptionOptions}`, `magnotia_transcription::LocalEngine`, `magnotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}`, `magnotia_storage::{database::get_profile, database::list_profile_terms, DEFAULT_PROFILE_ID}`. Plus `commands::audio::resolve_recording_path`, `commands::build_initial_prompt`, `commands::models::{default_model_id_for_engine, ensure_model_loaded}`, `commands::power::PowerAssertion`, `commands::security::ensure_main_window`. - Depends on: `lumotia_audio::{AudioChunk, CaptureRuntimeError, MicrophoneCapture, StreamingResampler, WavWriter}`, `lumotia_core::constants::WHISPER_SAMPLE_RATE`, `lumotia_core::types::{AudioSamples, Segment, TranscriptionOptions}`, `lumotia_transcription::LocalEngine`, `lumotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}`, `lumotia_storage::{database::get_profile, database::list_profile_terms, DEFAULT_PROFILE_ID}`. Plus `commands::audio::resolve_recording_path`, `commands::build_initial_prompt`, `commands::models::{default_model_id_for_engine, ensure_model_loaded}`, `commands::power::PowerAssertion`, `commands::security::ensure_main_window`.
- Called from frontend at: dictation page (when the user starts and stops a live session — most common entry). - Called from frontend at: dictation page (when the user starts and stops a live session — most common entry).
## What's in here ## What's in here
@@ -86,7 +86,7 @@ Methods:
1. `ensure_main_window`. 1. `ensure_main_window`.
2. `lifecycle.lock().await` — barrier against concurrent start/stop. 2. `lifecycle.lock().await` — barrier against concurrent start/stop.
3. Reject if a session is already running. 3. Reject if a session is already running.
4. Resolve profile_id, fetch profile + profile_terms from `magnotia_storage`. 4. Resolve profile_id, fetch profile + profile_terms from `lumotia_storage`.
5. Collapse the effective `initial_prompt` via `build_initial_prompt` (so the worker doesn't have to know about profile fallback). 5. Collapse the effective `initial_prompt` via `build_initial_prompt` (so the worker doesn't have to know about profile fallback).
6. Resolve model_id via `default_model_id_for_engine` if absent. 6. Resolve model_id via `default_model_id_for_engine` if absent.
7. `ensure_model_loaded(state, engine, model_id, None)``None` means don't enforce sequential-GPU mode (Settings owns that toggle). 7. `ensure_model_loaded(state, engine, model_id, None)``None` means don't enforce sequential-GPU mode (Settings owns that toggle).
@@ -104,7 +104,7 @@ Methods:
### `run_live_session` (`src-tauri/src/commands/live.rs:646`) ### `run_live_session` (`src-tauri/src/commands/live.rs:646`)
The blocking entry. Holds a `PowerAssertion::begin("magnotia live dictation session")` for the entire scope. Constructs and runs `LiveSessionRuntime`. The drop on the power assertion ends the macOS App Nap pin. The blocking entry. Holds a `PowerAssertion::begin("lumotia live dictation session")` for the entire scope. Constructs and runs `LiveSessionRuntime`. The drop on the power assertion ends the macOS App Nap pin.
### `maybe_dispatch_chunk` (`src-tauri/src/commands/live.rs:753`) ### `maybe_dispatch_chunk` (`src-tauri/src/commands/live.rs:753`)
@@ -172,7 +172,7 @@ frontend invoke('stop_live_transcription_session', { session_id })
- **Result-listener-lost path is critical.** Without it, closing the main window without a clean stop would leave the worker spinning forever, holding the GPU memory and the WAV file handle until process exit. The self-asserted stop flag is the safety net. - **Result-listener-lost path is critical.** Without it, closing the main window without a clean stop would leave the worker spinning forever, holding the GPU memory and the WAV file handle until process exit. The self-asserted stop flag is the safety net.
- **Power assertion only does work on macOS.** On Linux the function is a no-op (see [Power assertions and security](power-and-security.md)). A long live-dictation session on Linux can still be idled by the compositor. - **Power assertion only does work on macOS.** On Linux the function is a no-op (see [Power assertions and security](power-and-security.md)). A long live-dictation session on Linux can still be idled by the compositor.
- **The recent-segments history is bounded by time, not count.** A high chunk rate could grow it more than expected; the retention is `DUPLICATE_HISTORY_RETENTION_SECS = 8.0`. - **The recent-segments history is bounded by time, not count.** A high chunk rate could grow it more than expected; the retention is `DUPLICATE_HISTORY_RETENTION_SECS = 8.0`.
- **Channel back-pressure.** The result channel is the JS-side `Channel<T>` queue. If the frontend stops reading, the queue grows. Magnotia's overload-signalling currently uses the in-buffer `MAX_PENDING_SAMPLES` cap; it does NOT detect a JS-side stalled listener except via the `emit_live_result`-failure path. - **Channel back-pressure.** The result channel is the JS-side `Channel<T>` queue. If the frontend stops reading, the queue grows. Lumotia's overload-signalling currently uses the in-buffer `MAX_PENDING_SAMPLES` cap; it does NOT detect a JS-side stalled listener except via the `emit_live_result`-failure path.
- **`ensure_model_loaded(state, engine, model_id, None)`** intentionally passes `None` for `concurrent`, so live sessions never trigger the sequential-GPU guard in `commands::models`. If you ever ship a tight-VRAM machine and the user has switched to sequential mode, this could OOM. Today's hardware survey indicates this is uncommon; flag this when revisiting Phase A.4. - **`ensure_model_loaded(state, engine, model_id, None)`** intentionally passes `None` for `concurrent`, so live sessions never trigger the sequential-GPU guard in `commands::models`. If you ever ship a tight-VRAM machine and the user has switched to sequential mode, this could OOM. Today's hardware survey indicates this is uncommon; flag this when revisiting Phase A.4.
## See also ## See also

View File

@@ -18,7 +18,7 @@ last_verified: 2026/05/09
- Tauri commands exposed (10 total): - Tauri commands exposed (10 total):
- `recommend_llm_tier() -> Result<String, String>`. No window guard — pure hardware probe. - `recommend_llm_tier() -> Result<String, String>`. No window guard — pure hardware probe.
- `check_llm_model(state, model_id) -> Result<LlmModelStatusDto, String>`. - `check_llm_model(state, model_id) -> Result<LlmModelStatusDto, String>`.
- `download_llm_model(window, app, model_id) -> Result<(), String>`. Main-window only. Emits `magnotia:llm-download-progress`. - `download_llm_model(window, app, model_id) -> Result<(), String>`. Main-window only. Emits `lumotia:llm-download-progress`.
- `load_llm_model(window, state, model_id, use_gpu, concurrent) -> Result<(), String>`. Main-window only. - `load_llm_model(window, state, model_id, use_gpu, concurrent) -> Result<(), String>`. Main-window only.
- `unload_llm_model(window, state) -> Result<(), String>`. Main-window only. - `unload_llm_model(window, state) -> Result<(), String>`. Main-window only.
- `delete_llm_model(window, state, model_id) -> Result<(), String>`. Main-window only. - `delete_llm_model(window, state, model_id) -> Result<(), String>`. Main-window only.
@@ -26,8 +26,8 @@ last_verified: 2026/05/09
- `test_llm_model(window, state, model_id) -> Result<LlmTestResult, String>`. Main-window only. - `test_llm_model(window, state, model_id) -> Result<LlmTestResult, String>`. Main-window only.
- `cleanup_transcript_text_cmd(window, state, transcript, profile_id, preset) -> Result<String, String>`. Main-window only. - `cleanup_transcript_text_cmd(window, state, transcript, profile_id, preset) -> Result<String, String>`. Main-window only.
- `extract_content_tags_cmd(state, transcript) -> Result<ContentTags, String>`. - `extract_content_tags_cmd(state, transcript) -> Result<ContentTags, String>`.
- Events emitted: `magnotia:llm-download-progress` (`{ modelId, done, total, percent }`) — `src-tauri/src/commands/llm.rs:76`. - Events emitted: `lumotia:llm-download-progress` (`{ modelId, done, total, percent }`) — `src-tauri/src/commands/llm.rs:76`.
- Depends on: `magnotia_llm::{LlmEngine, LlmModelId, ContentTags, model_manager::{download_model, model_path, model_info, recommend_tier, is_downloaded, delete_model}}`, `magnotia_ai_formatting::{llm_cleanup_text, LlmPromptPreset}`, `magnotia_core::hardware`, `magnotia_storage::database::list_profile_terms`. Plus `commands::power::PowerAssertion`, `commands::security::ensure_main_window`. - Depends on: `lumotia_llm::{LlmEngine, LlmModelId, ContentTags, model_manager::{download_model, model_path, model_info, recommend_tier, is_downloaded, delete_model}}`, `lumotia_ai_formatting::{llm_cleanup_text, LlmPromptPreset}`, `lumotia_core::hardware`, `lumotia_storage::database::list_profile_terms`. Plus `commands::power::PowerAssertion`, `commands::security::ensure_main_window`.
- Called from frontend at: Settings → AI page (download / load / unload / delete / test), dictation result panel (`cleanup_transcript_text_cmd`), History page (`extract_content_tags_cmd`). - Called from frontend at: Settings → AI page (download / load / unload / delete / test), dictation result panel (`cleanup_transcript_text_cmd`), History page (`extract_content_tags_cmd`).
## What's in here ## What's in here
@@ -42,7 +42,7 @@ Wraps `model_id.parse()` (which goes through the `LlmModelId` parser).
### `recommend_llm_tier` (`:28`) ### `recommend_llm_tier` (`:28`)
Probes RAM via `magnotia_core::hardware::probe_system`, multiplies the MB to bytes, then defers to `magnotia_llm::model_manager::recommend_tier(ram, vram)`. No window guard — Settings calls this at first paint to populate the default tier suggestion. Probes RAM via `lumotia_core::hardware::probe_system`, multiplies the MB to bytes, then defers to `lumotia_llm::model_manager::recommend_tier(ram, vram)`. No window guard — Settings calls this at first paint to populate the default tier suggestion.
### `check_llm_model` (`:40`) ### `check_llm_model` (`:40`)
@@ -50,7 +50,7 @@ Combines `model_info(id)` (static metadata), `model_manager::is_downloaded(id)`,
### `download_llm_model` (`:61`) ### `download_llm_model` (`:61`)
`ensure_main_window`. Calls `model_manager::download_model(id, progress_cb)` with a closure that emits `magnotia:llm-download-progress` on each chunk. The percent calculation rounds against `total > 0` (avoids division by zero on a server that doesn't return a content-length). `ensure_main_window`. Calls `model_manager::download_model(id, progress_cb)` with a closure that emits `lumotia:llm-download-progress` on each chunk. The percent calculation rounds against `total > 0` (avoids division by zero on a server that doesn't return a content-length).
### `load_llm_model` (`:90`) ### `load_llm_model` (`:90`)
@@ -75,7 +75,7 @@ Pure string classifier. Tested independently. Buckets: `load-failed-vram` (looks
### `cleanup_transcript_text_cmd` (`:362`) ### `cleanup_transcript_text_cmd` (`:362`)
`ensure_main_window`. Resolves `profile_id`. Fetches profile_terms from storage. Resolves the `preset` (Brief item B.1 #15: `Default`, `Email`, `Notes`, `Code`). `spawn_blocking` runs `llm_cleanup_text(&engine, &transcript, &profile_terms, resolved_preset)` inside a `PowerAssertion::begin("magnotia LLM cleanup")` so macOS App Nap doesn't throttle mid-token. `ensure_main_window`. Resolves `profile_id`. Fetches profile_terms from storage. Resolves the `preset` (Brief item B.1 #15: `Default`, `Email`, `Notes`, `Code`). `spawn_blocking` runs `llm_cleanup_text(&engine, &transcript, &profile_terms, resolved_preset)` inside a `PowerAssertion::begin("lumotia LLM cleanup")` so macOS App Nap doesn't throttle mid-token.
### `extract_content_tags_cmd` (`:407`) ### `extract_content_tags_cmd` (`:407`)
@@ -91,7 +91,7 @@ Eight `classify_llm_load_error` cases cover the four classification buckets and
Settings -> recommend_llm_tier() -> hardware probe -> tier string Settings -> recommend_llm_tier() -> hardware probe -> tier string
Settings -> download_llm_model(model_id) -> model_manager::download_model Settings -> download_llm_model(model_id) -> model_manager::download_model
-> per-chunk progress events -> per-chunk progress events
-> file lands in ~/.magnotia/models/llm/ -> file lands in ~/.lumotia/models/llm/
Settings -> load_llm_model(model_id, use_gpu, concurrent) Settings -> load_llm_model(model_id, use_gpu, concurrent)
-> if concurrent=false: unload whisper + parakeet -> if concurrent=false: unload whisper + parakeet
-> spawn_blocking(engine.load_model) -> spawn_blocking(engine.load_model)

View File

@@ -51,7 +51,7 @@ Six test cases cover each branch of the precedence rule plus whitespace handling
## Data flow ## Data flow
The helper is called *after* the calling command has read the relevant `ProfileRow` and `ProfileTermRow`s from `magnotia_storage`. The DB I/O lives in the calling command (so the tests in this file stay pure). The helper is called *after* the calling command has read the relevant `ProfileRow` and `ProfileTermRow`s from `lumotia_storage`. The DB I/O lives in the calling command (so the tests in this file stay pure).
## Watch-outs ## Watch-outs

View File

@@ -20,7 +20,7 @@ last_verified: 2026/05/09
- Parakeet: `download_parakeet_model(name)`, `check_parakeet_model(name)`, `list_parakeet_models()`, `load_parakeet_model(name, concurrent)`, `check_parakeet_engine()`. - Parakeet: `download_parakeet_model(name)`, `check_parakeet_model(name)`, `list_parakeet_models()`, `load_parakeet_model(name, concurrent)`, `check_parakeet_engine()`.
- Public Rust helpers (used by other command modules): `default_model_id_for_engine`, `ensure_model_loaded`, `prewarm_default_model`, `load_model_from_disk`, `detect_active_compute_device`, `emit_runtime_warnings`. - Public Rust helpers (used by other command modules): `default_model_id_for_engine`, `ensure_model_loaded`, `prewarm_default_model`, `load_model_from_disk`, `detect_active_compute_device`, `emit_runtime_warnings`.
- Events emitted: `model-download-progress` (whisper), `parakeet-download-progress`, `runtime-warning` (tagged enum: `Avx2Missing`, `VulkanLoaderMissing`, future `CudaFallback`). - Events emitted: `model-download-progress` (whisper), `parakeet-download-progress`, `runtime-warning` (tagged enum: `Avx2Missing`, `VulkanLoaderMissing`, future `CudaFallback`).
- Depends on: `magnotia_transcription::{model_manager, load_whisper, load_parakeet, LocalEngine, Transcriber}`, `magnotia_core::{model_registry, hardware, constants, types}`. - Depends on: `lumotia_transcription::{model_manager, load_whisper, load_parakeet, LocalEngine, Transcriber}`, `lumotia_core::{model_registry, hardware, constants, types}`.
- Called from frontend at: Settings → Models page (download, load, status), the dictation page boot path (`prewarm_default_model_cmd`), Settings → About (runtime capabilities). - Called from frontend at: Settings → Models page (download, load, status), the dictation page boot path (`prewarm_default_model_cmd`), Settings → About (runtime capabilities).
## What's in here ## What's in here
@@ -99,8 +99,8 @@ Five `compose_accelerators` permutations cover the RB-07 regression. Confirm:
- **The `concurrent` flag is a tri-state.** `None` and `Some(true)` keep the legacy parallel-residency behaviour. Only `Some(false)` triggers the unload-the-other-engine guard. Live transcription explicitly passes `None`. If you ever ship a 4 GB-VRAM Settings preset that flips `concurrent=false` by default, also test the live transcription path. - **The `concurrent` flag is a tri-state.** `None` and `Some(true)` keep the legacy parallel-residency behaviour. Only `Some(false)` triggers the unload-the-other-engine guard. Live transcription explicitly passes `None`. If you ever ship a 4 GB-VRAM Settings preset that flips `concurrent=false` by default, also test the live transcription path.
- **`parallel_mode_available` is hard-wired to `false` until Phase A.4 lands a real GPU VRAM probe** (`src-tauri/src/commands/models.rs:509`). Today's frontend reads this and disables the toggle. Flag for follow-up. - **`parallel_mode_available` is hard-wired to `false` until Phase A.4 lands a real GPU VRAM probe** (`src-tauri/src/commands/models.rs:509`). Today's frontend reads this and disables the toggle. Flag for follow-up.
- **`prewarm_default_model` only runs whisper.** Parakeet has no warm-up. The Parakeet model is also smaller and loads faster, so the cold-start gap is less obvious. If you swap Magnotia's default to Parakeet, add an equivalent warm-up. - **`prewarm_default_model` only runs whisper.** Parakeet has no warm-up. The Parakeet model is also smaller and loads faster, so the cold-start gap is less obvious. If you swap Lumotia's default to Parakeet, add an equivalent warm-up.
- **Vulkan loader detection is per-platform** (`magnotia_core::hardware::vulkan_loader_available`). The CPU-fallback `reason` strings are user-visible; keep them actionable (the Linux one names `libvulkan1`, the macOS one names the Vulkan SDK runtime). - **Vulkan loader detection is per-platform** (`lumotia_core::hardware::vulkan_loader_available`). The CPU-fallback `reason` strings are user-visible; keep them actionable (the Linux one names `libvulkan1`, the macOS one names the Vulkan SDK runtime).
- **Whisper feature gating.** `--no-default-features` builds compile but `load_model_from_disk` returns a runtime error when the user requests a Whisper model. `list_models` will still show whisper models that happened to be downloaded already; consider hiding them when the feature is off if a no-whisper build ever ships to users. - **Whisper feature gating.** `--no-default-features` builds compile but `load_model_from_disk` returns a runtime error when the user requests a Whisper model. `list_models` will still show whisper models that happened to be downloaded already; consider hiding them when the feature is off if a no-whisper build ever ships to users.
- **Download progress events fire from a closure inside `model_manager::download`.** That closure is called from inside an async `spawn_blocking`-equivalent. The `let _ = app_clone.emit(...)` pattern silently drops emit errors; if a window has been closed mid-download the progress events stop landing but the download itself completes. - **Download progress events fire from a closure inside `model_manager::download`.** That closure is called from inside an async `spawn_blocking`-equivalent. The `let _ = app_clone.emit(...)` pattern silently drops emit errors; if a window has been closed mid-download the progress events stop landing but the download itself completes.

View File

@@ -9,7 +9,7 @@ last_verified: 2026/05/09
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Paste > **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Paste
**Plain English summary.** Auto-insert-at-cursor: copy the transcript onto the clipboard and synthesise the platform's paste keystroke (Ctrl+V / Cmd+V) so the text lands in whatever app the user was already focused on. Adds a replace-with-raw flow that fires undo first. Skips the keystroke when the focused window is a terminal emulator (terminals duplicate the keystroke through the PTY). Hides the always-on-top preview overlay before the keystroke so a Wayland compositor doesn't accidentally route the paste back into Magnotia. Restores the user's prior clipboard 300 ms after the paste to honour the "never silently clobber the user's clipboard" contract. **Plain English summary.** Auto-insert-at-cursor: copy the transcript onto the clipboard and synthesise the platform's paste keystroke (Ctrl+V / Cmd+V) so the text lands in whatever app the user was already focused on. Adds a replace-with-raw flow that fires undo first. Skips the keystroke when the focused window is a terminal emulator (terminals duplicate the keystroke through the PTY). Hides the always-on-top preview overlay before the keystroke so a Wayland compositor doesn't accidentally route the paste back into Lumotia. Restores the user's prior clipboard 300 ms after the paste to honour the "never silently clobber the user's clipboard" contract.
## At a glance ## At a glance
@@ -48,7 +48,7 @@ Step-by-step:
### `paste_text_replacing` (`src-tauri/src/commands/paste.rs:185`) ### `paste_text_replacing` (`src-tauri/src/commands/paste.rs:185`)
Same shape as `paste_text` but with an extra step: after copying and hiding the preview, fire `trigger_undo_keystroke()`, sleep 60 ms, then paste. The undo removes whatever text Magnotia already inserted (the cleaned-up transcript), the paste inserts the raw text. Used by the "replace with raw" frontend button per brief item #17. Same shape as `paste_text` but with an extra step: after copying and hiding the preview, fire `trigger_undo_keystroke()`, sleep 60 ms, then paste. The undo removes whatever text Lumotia already inserted (the cleaned-up transcript), the paste inserts the raw text. Used by the "replace with raw" frontend button per brief item #17.
### `detect_paste_backends` (`src-tauri/src/commands/paste.rs:258`) ### `detect_paste_backends` (`src-tauri/src/commands/paste.rs:258`)
@@ -97,13 +97,13 @@ Replace flow inserts an `undo` keystroke and a 60 ms gap between hide and paste.
## Watch-outs ## Watch-outs
- **Focus must already be on the target window when the keystroke fires.** The global hotkey flow preserves this naturally; clicking Magnotia's own UI does not. The frontend Settings page surfaces the caveat next to the toggle. - **Focus must already be on the target window when the keystroke fires.** The global hotkey flow preserves this naturally; clicking Lumotia's own UI does not. The frontend Settings page surfaces the caveat next to the toggle.
- **Wayland focused-window probe is intentionally absent.** No way to do this from an unprivileged Wayland client. Result: terminal detection on Wayland-Kitty users will miss; they fall back to the manual Ctrl+Shift+V they already use. - **Wayland focused-window probe is intentionally absent.** No way to do this from an unprivileged Wayland client. Result: terminal detection on Wayland-Kitty users will miss; they fall back to the manual Ctrl+Shift+V they already use.
- **Clipboard restore is best-effort.** If the user copies something else within the 300 ms window, `should_restore` will (correctly) decline to write back. If a slow Wayland compositor delays the keystroke past 300 ms, we restore too early and the synthesised Ctrl+V pastes the user's old clipboard. Tradeoff documented in Handy #921. - **Clipboard restore is best-effort.** If the user copies something else within the 300 ms window, `should_restore` will (correctly) decline to write back. If a slow Wayland compositor delays the keystroke past 300 ms, we restore too early and the synthesised Ctrl+V pastes the user's old clipboard. Tradeoff documented in Handy #921.
- **Linux backend order is session-aware.** A user with `XDG_SESSION_TYPE=wayland` and only xdotool installed will fall through to xdotool with a warning; their X11-app focus on a Wayland session works fine but Wayland-native apps will not see the keystroke. - **Linux backend order is session-aware.** A user with `XDG_SESSION_TYPE=wayland` and only xdotool installed will fall through to xdotool with a warning; their X11-app focus on a Wayland session works fine but Wayland-native apps will not see the keystroke.
- **No backend = clipboard-only.** If `trigger_paste_keystroke` fails on Linux (no tools installed), the user still has the transcript on the clipboard; Settings shows the install-wtype hint via `detect_paste_backends`. - **No backend = clipboard-only.** If `trigger_paste_keystroke` fails on Linux (no tools installed), the user still has the transcript on the clipboard; Settings shows the install-wtype hint via `detect_paste_backends`.
- **PowerShell process spawn cost on Windows.** Each paste spawns a fresh PowerShell. Acceptable for one-off paste invocations; if you ever build a streaming-paste mode, switch to native `SendInput` via the `windows` crate. - **PowerShell process spawn cost on Windows.** Each paste spawns a fresh PowerShell. Acceptable for one-off paste invocations; if you ever build a streaming-paste mode, switch to native `SendInput` via the `windows` crate.
- **Permissions.** The macOS path requires the user to have granted Accessibility permissions to Magnotia (System Settings → Privacy → Accessibility). Without that, `osascript` returns success but the keystroke is silently dropped. There is no probe today; flag this in onboarding. - **Permissions.** The macOS path requires the user to have granted Accessibility permissions to Lumotia (System Settings → Privacy → Accessibility). Without that, `osascript` returns success but the keystroke is silently dropped. There is no probe today; flag this in onboarding.
## See also ## See also

View File

@@ -55,9 +55,9 @@ macOS-only. Wraps `NSProcessInfo::processInfo().beginActivityWithOptions_reason(
### Where `PowerAssertion::begin` is called ### Where `PowerAssertion::begin` is called
- `commands::live::run_live_session` (`live.rs:660`) — `"magnotia live dictation session"`. - `commands::live::run_live_session` (`live.rs:660`) — `"lumotia live dictation session"`.
- `commands::llm::cleanup_transcript_text_cmd` (`llm.rs:394`) — `"magnotia LLM cleanup"`. - `commands::llm::cleanup_transcript_text_cmd` (`llm.rs:394`) — `"lumotia LLM cleanup"`.
- `commands::llm::extract_content_tags_cmd` (`llm.rs:417`) — `"magnotia LLM content-tag extraction"`. - `commands::llm::extract_content_tags_cmd` (`llm.rs:417`) — `"lumotia LLM content-tag extraction"`.
NOT called from `commands::tasks::decompose_and_store` or `commands::tasks::extract_tasks_from_transcript_cmd`, even though both run multi-second LLM inference. Flag for follow-up. NOT called from `commands::tasks::decompose_and_store` or `commands::tasks::extract_tasks_from_transcript_cmd`, even though both run multi-second LLM inference. Flag for follow-up.

View File

@@ -26,7 +26,7 @@ last_verified: 2026/05/09
- `learn_profile_terms_from_edit_cmd(state, profile_id, original_text, edited_text) -> Result<Vec<ProfileTermDto>, String>`. - `learn_profile_terms_from_edit_cmd(state, profile_id, original_text, edited_text) -> Result<Vec<ProfileTermDto>, String>`.
- `delete_profile_term_cmd(state, id) -> Result<(), String>`. - `delete_profile_term_cmd(state, id) -> Result<(), String>`.
- Events emitted: none. - Events emitted: none.
- Depends on: `magnotia_storage::{create_profile, update_profile, delete_profile, list_profiles, get_profile, add_profile_term, list_profile_terms, delete_profile_term, ProfileRow, ProfileTermRow}`, `magnotia_ai_formatting::extract_corrections`. - Depends on: `lumotia_storage::{create_profile, update_profile, delete_profile, list_profiles, get_profile, add_profile_term, list_profile_terms, delete_profile_term, ProfileRow, ProfileTermRow}`, `lumotia_ai_formatting::extract_corrections`.
- Called from frontend at: Settings → Profiles (full CRUD), profile picker, History viewer (the auto-learn flow runs after the user saves an edit). - Called from frontend at: Settings → Profiles (full CRUD), profile picker, History viewer (the auto-learn flow runs after the user saves an edit).
## What's in here ## What's in here
@@ -47,7 +47,7 @@ Constant so the auto-learn rows are uniformly tagged.
### `learn_profile_terms_from_edit_cmd` (`:151`) ### `learn_profile_terms_from_edit_cmd` (`:151`)
1. Pull the existing terms (so the diff doesn't propose duplicates). 1. Pull the existing terms (so the diff doesn't propose duplicates).
2. Call `magnotia_ai_formatting::extract_corrections(&original, &edited, &existing_terms)`. 2. Call `lumotia_ai_formatting::extract_corrections(&original, &edited, &existing_terms)`.
3. Persist each new term via `add_profile_term` with the `AUTO_LEARNED_NOTE`. 3. Persist each new term via `add_profile_term` with the `AUTO_LEARNED_NOTE`.
4. Return the freshly-inserted DTOs. 4. Return the freshly-inserted DTOs.

View File

@@ -47,11 +47,11 @@ Frontend-facing structs. `SystemInfo` carries `ram_mb`, `cpu_brand`, `cpu_cores`
### `probe_system() -> Result<SystemInfo, String>` (`src-tauri/src/commands/hardware.rs:29`) ### `probe_system() -> Result<SystemInfo, String>` (`src-tauri/src/commands/hardware.rs:29`)
Wraps `magnotia_core::hardware::probe_system`. Maps the OS enum to a string and the GPU vendor (if probed) to its `Debug` form. Wraps `lumotia_core::hardware::probe_system`. Maps the OS enum to a string and the GPU vendor (if probed) to its `Debug` form.
### `rank_models() -> Result<Vec<ModelRecommendation>, String>` (`src-tauri/src/commands/hardware.rs:49`) ### `rank_models() -> Result<Vec<ModelRecommendation>, String>` (`src-tauri/src/commands/hardware.rs:49`)
Calls `magnotia_core::recommendation::rank_recommendations` and decorates each entry with `magnotia_transcription::is_downloaded`. Used by Settings → Models for the "recommended for your hardware" list. Calls `lumotia_core::recommendation::rank_recommendations` and decorates each entry with `lumotia_transcription::is_downloaded`. Used by Settings → Models for the "recommended for your hardware" list.
Both commands are unguarded (any window can call). Pure read-only probes. Both commands are unguarded (any window can call). Pure read-only probes.
@@ -65,7 +65,7 @@ Tauri-managed: `lister: Mutex<ProcessLister>`. Holds a long-lived `ProcessLister
Phase 8 meeting auto-capture (single-signal variant). Frontend polls this on an interval with the user's app patterns. On a positive hit, the frontend surfaces a non-modal toast that reminds the user to start recording with their hotkey. We do NOT start recording from this signal — the user decides. Phase 8 meeting auto-capture (single-signal variant). Frontend polls this on an interval with the user's app patterns. On a positive hit, the frontend surfaces a non-modal toast that reminds the user to start recording with their hotkey. We do NOT start recording from this signal — the user decides.
If `patterns` is empty, returns an empty Vec without locking. Otherwise locks the `lister`, snapshots the process list, and runs `magnotia_core::process_watch::match_meeting_patterns` to filter. Returns the matched process names. If `patterns` is empty, returns an empty Vec without locking. Otherwise locks the `lister`, snapshots the process list, and runs `lumotia_core::process_watch::match_meeting_patterns` to filter. Returns the matched process names.
Watch-out: the `ProcessLister` lock is `std::sync::Mutex`. If the snapshot ever takes meaningful time, switch to async. Watch-out: the `ProcessLister` lock is `std::sync::Mutex`. If the snapshot ever takes meaningful time, switch to async.
@@ -77,7 +77,7 @@ Watch-out: the `ProcessLister` lock is `std::sync::Mutex`. If the snapshot ever
### `deliver_nudge(app, window, input: DeliverNudgeInput) -> Result<(), String>` (`src-tauri/src/commands/nudges.rs:42`) ### `deliver_nudge(app, window, input: DeliverNudgeInput) -> Result<(), String>` (`src-tauri/src/commands/nudges.rs:42`)
Phase 6. Main-window only via `ensure_main_window`. Trims title and body; if both are empty, return Ok silently (a blank nudge is worse than no nudge). Defaults the title to `"Magnotia"` if only the body is present. Calls `tauri_plugin_notification::NotificationExt::notification().builder().title(...).body(...).show()`. Phase 6. Main-window only via `ensure_main_window`. Trims title and body; if both are empty, return Ok silently (a blank nudge is worse than no nudge). Defaults the title to `"Lumotia"` if only the body is present. Calls `tauri_plugin_notification::NotificationExt::notification().builder().title(...).body(...).show()`.
The frontend nudge bus (`nudgeBus.svelte.ts`) owns cadence, suppression, and the hourly cap. This command is a blunt "push it now" primitive — no rate limiting at the Rust layer. Errors propagate verbatim so the bus can log + swallow. The frontend nudge bus (`nudgeBus.svelte.ts`) owns cadence, suppression, and the hourly cap. This command is a blunt "push it now" primitive — no rate limiting at the Rust layer. Errors propagate verbatim so the bus can log + swallow.
@@ -85,7 +85,7 @@ The frontend nudge bus (`nudgeBus.svelte.ts`) owns cadence, suppression, and the
### Morning-triage sentinel ### Morning-triage sentinel
Frontend owns rendering and logic; this module only persists the "last date the morning triage modal was shown" sentinel under SQLite settings key `magnotia_morning_triage_last_shown`. Frontend owns rendering and logic; this module only persists the "last date the morning triage modal was shown" sentinel under SQLite settings key `lumotia_morning_triage_last_shown`.
- `get_last_morning_triage(state) -> Result<Option<String>, String>` (`src-tauri/src/commands/rituals.rs:23`). - `get_last_morning_triage(state) -> Result<Option<String>, String>` (`src-tauri/src/commands/rituals.rs:23`).
- `mark_morning_triage_shown(state, date: String) -> Result<(), String>` (`src-tauri/src/commands/rituals.rs:36`). Caller passes a YYYY-MM-DD string in the user's local timezone — Rust deliberately stays timezone-agnostic. - `mark_morning_triage_shown(state, date: String) -> Result<(), String>` (`src-tauri/src/commands/rituals.rs:36`). Caller passes a YYYY-MM-DD string in the user's local timezone — Rust deliberately stays timezone-agnostic.

View File

@@ -9,7 +9,7 @@ last_verified: 2026/05/09
> **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Tasks > **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Tasks
**Plain English summary.** Eleven commands wrapping the `magnotia_storage` task CRUD plus two LLM-driven actions. Standard CRUD: create / list / update / complete / uncomplete / delete a task, plus subtask CRUD (insert / list / complete) and a daily-completion-counts query for the Phase 8 Tasks-page momentum sparkline. The two LLM actions: `decompose_and_store` (break a parent task into subtasks via the local LLM with HITL feedback as few-shot exemplars) and `extract_tasks_from_transcript_cmd` (extract task lines from a recently-finished transcript, again with feedback exemplars). **Plain English summary.** Eleven commands wrapping the `lumotia_storage` task CRUD plus two LLM-driven actions. Standard CRUD: create / list / update / complete / uncomplete / delete a task, plus subtask CRUD (insert / list / complete) and a daily-completion-counts query for the Phase 8 Tasks-page momentum sparkline. The two LLM actions: `decompose_and_store` (break a parent task into subtasks via the local LLM with HITL feedback as few-shot exemplars) and `extract_tasks_from_transcript_cmd` (extract task lines from a recently-finished transcript, again with feedback exemplars).
## At a glance ## At a glance
@@ -29,7 +29,7 @@ last_verified: 2026/05/09
- `complete_subtask_cmd(state, subtask_id) -> Result<(), String>`. - `complete_subtask_cmd(state, subtask_id) -> Result<(), String>`.
- `list_recent_completions_cmd(state, days) -> Result<Vec<DailyCompletionCount>, String>`. - `list_recent_completions_cmd(state, days) -> Result<Vec<DailyCompletionCount>, String>`.
- Events emitted: none. - Events emitted: none.
- Depends on: `magnotia_storage::{insert_task, list_tasks, update_task, complete_task, uncomplete_task, delete_task, set_task_energy, get_task_by_id, insert_subtask, list_subtasks, complete_subtask_and_check_parent, list_recent_completions, list_feedback_examples, FeedbackTargetType, TaskRow, DailyCompletionCount, FeedbackRow}`, `magnotia_llm::prompts::FeedbackExample`, `uuid::Uuid`. - Depends on: `lumotia_storage::{insert_task, list_tasks, update_task, complete_task, uncomplete_task, delete_task, set_task_energy, get_task_by_id, insert_subtask, list_subtasks, complete_subtask_and_check_parent, list_recent_completions, list_feedback_examples, FeedbackTargetType, TaskRow, DailyCompletionCount, FeedbackRow}`, `lumotia_llm::prompts::FeedbackExample`, `uuid::Uuid`.
- Called from frontend at: Tasks page (CRUD, subtasks, sparkline), dictation result panel ("Extract tasks" button), parent-task expand UI ("Decompose"). - Called from frontend at: Tasks page (CRUD, subtasks, sparkline), dictation result panel ("Extract tasks" button), parent-task expand UI ("Decompose").
## What's in here ## What's in here
@@ -106,7 +106,7 @@ Dictation panel Extract tasks -> extract_tasks_from_transcript_cmd(text, profile
## Watch-outs ## Watch-outs
- **No `ensure_main_window` guard.** Tasks UI lives in the main window AND in the always-on-top task float (which has the secondary-windows capability). The float can call list / complete / set-energy / list-recent-completions etc. — that's intentional — but it can also fire `decompose_and_store` and `extract_tasks_from_transcript_cmd`, which spend LLM tokens. If you want to lock this down, add the guard to the LLM-spending commands. - **No `ensure_main_window` guard.** Tasks UI lives in the main window AND in the always-on-top task float (which has the secondary-windows capability). The float can call list / complete / set-energy / list-recent-completions etc. — that's intentional — but it can also fire `decompose_and_store` and `extract_tasks_from_transcript_cmd`, which spend LLM tokens. If you want to lock this down, add the guard to the LLM-spending commands.
- **No `PowerAssertion`.** `decompose_and_store` and `extract_tasks_from_transcript_cmd` both run synchronous LLM inference for several seconds. macOS can App Nap them. Add `PowerAssertion::begin("magnotia LLM task decomposition")` and similar. - **No `PowerAssertion`.** `decompose_and_store` and `extract_tasks_from_transcript_cmd` both run synchronous LLM inference for several seconds. macOS can App Nap them. Add `PowerAssertion::begin("lumotia LLM task decomposition")` and similar.
- **The patch shape passes `Option<String>` for every column.** Currently the storage layer's `update_task` uses COALESCE: `Some` overwrites, `None` preserves. This means there's no way to clear `notes` or `effort` to empty via `update_task_cmd` — you can only set them to a non-empty string. If a user wants to clear a field, they'd need a fresh task or a dedicated clear command. - **The patch shape passes `Option<String>` for every column.** Currently the storage layer's `update_task` uses COALESCE: `Some` overwrites, `None` preserves. This means there's no way to clear `notes` or `effort` to empty via `update_task_cmd` — you can only set them to a non-empty string. If a user wants to clear a field, they'd need a fresh task or a dedicated clear command.
- **Decompose stores subtasks one-by-one in a loop** (`:329`). Each iteration is a separate DB transaction. Acceptable for typical 37-step decompositions; if a future LLM produces 50 steps, batch the inserts. - **Decompose stores subtasks one-by-one in a loop** (`:329`). Each iteration is a separate DB transaction. Acceptable for typical 37-step decompositions; if a future LLM produces 50 steps, batch the inserts.
- **`extract_tasks_from_transcript_cmd` returns just `Vec<String>` and does NOT insert.** Frontend chooses what to insert. Confusing because the sibling `decompose_and_store` *does* insert. Convention is dictated by UX (decomposition is one-click, extraction reviews-then-inserts). - **`extract_tasks_from_transcript_cmd` returns just `Vec<String>` and does NOT insert.** Frontend chooses what to insert. Confusing because the sibling `decompose_and_store` *does* insert. Convention is dictated by UX (decomposition is one-click, extraction reviews-then-inserts).

View File

@@ -20,7 +20,7 @@ last_verified: 2026/05/09
- `transcribe_file(window, state, path, engine: Option<String>, model_id: Option<String>, language, initial_prompt, remove_fillers, british_english, anti_hallucination, format_mode, profile_id) -> Result<serde_json::Value, String>` — main-window only. Decodes the file, picks engine (default whisper), returns the result inline. - `transcribe_file(window, state, path, engine: Option<String>, model_id: Option<String>, language, initial_prompt, remove_fillers, british_english, anti_hallucination, format_mode, profile_id) -> Result<serde_json::Value, String>` — main-window only. Decodes the file, picks engine (default whisper), returns the result inline.
- `transcribe_pcm_parakeet(window, state, app, samples, chunk_id, remove_fillers, british_english, anti_hallucination, format_mode, profile_id) -> Result<(), String>` — main-window only. Parakeet PCM. Emits `transcription-result`. - `transcribe_pcm_parakeet(window, state, app, samples, chunk_id, remove_fillers, british_english, anti_hallucination, format_mode, profile_id) -> Result<(), String>` — main-window only. Parakeet PCM. Emits `transcription-result`.
- Events emitted: `transcription-result` (payload: `{ status: "transcription", segments, language, duration, chunk_id, inference_ms, raw_text }`) — fires from `transcribe_pcm` (`src-tauri/src/commands/transcription.rs:208`) and `transcribe_pcm_parakeet` (`:398`). - Events emitted: `transcription-result` (payload: `{ status: "transcription", segments, language, duration, chunk_id, inference_ms, raw_text }`) — fires from `transcribe_pcm` (`src-tauri/src/commands/transcription.rs:208`) and `transcribe_pcm_parakeet` (`:398`).
- Depends on: `magnotia_audio::{decode_audio_file_limited, resample_to_16khz, probe_audio_duration_secs}`, `magnotia_core::types::{AudioSamples, Segment, Transcript, TranscriptionOptions}`, `magnotia_transcription::{LocalEngine, TimedTranscript}`, `magnotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}`, `magnotia_storage::{database, DEFAULT_PROFILE_ID}`. Plus `commands::build_initial_prompt`, `commands::models::{default_model_id_for_engine, ensure_model_loaded}`, `commands::security::ensure_main_window`. - Depends on: `lumotia_audio::{decode_audio_file_limited, resample_to_16khz, probe_audio_duration_secs}`, `lumotia_core::types::{AudioSamples, Segment, Transcript, TranscriptionOptions}`, `lumotia_transcription::{LocalEngine, TimedTranscript}`, `lumotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}`, `lumotia_storage::{database, DEFAULT_PROFILE_ID}`. Plus `commands::build_initial_prompt`, `commands::models::{default_model_id_for_engine, ensure_model_loaded}`, `commands::security::ensure_main_window`.
- Called from frontend at: dictation page (PCM commands when not live), file-import flow (transcribe_file), Settings test page (file command for QA). - Called from frontend at: dictation page (PCM commands when not live), file-import flow (transcribe_file), Settings test page (file command for QA).
## What's in here ## What's in here
@@ -55,7 +55,7 @@ Whisper-specific PCM path (no chunking — frontend is expected to keep the buff
1. `ensure_main_window`. 1. `ensure_main_window`.
2. Resolve `profile_id` (default `DEFAULT_PROFILE_ID`). 2. Resolve `profile_id` (default `DEFAULT_PROFILE_ID`).
3. Fetch `ProfileRow` and profile term list from `magnotia_storage::database`. 3. Fetch `ProfileRow` and profile term list from `lumotia_storage::database`.
4. Build effective Whisper prompt via `build_initial_prompt(&caller_prompt, &profile.initial_prompt, &profile_terms)`. 4. Build effective Whisper prompt via `build_initial_prompt(&caller_prompt, &profile.initial_prompt, &profile_terms)`.
5. `spawn_blocking` runs `engine.transcribe_sync` on the samples. 5. `spawn_blocking` runs `engine.transcribe_sync` on the samples.
6. Run `post_process_segments` (filler removal, British English conversion, anti-hallucination, format mode, dictionary terms, optional LLM cleanup via `state.llm_engine`). 6. Run `post_process_segments` (filler removal, British English conversion, anti-hallucination, format mode, dictionary terms, optional LLM cleanup via `state.llm_engine`).
@@ -67,7 +67,7 @@ Whisper-specific PCM path (no chunking — frontend is expected to keep the buff
2. Resolve profile + terms (same as PCM path). 2. Resolve profile + terms (same as PCM path).
3. Default engine to `"whisper"`, default model id to `default_model_id_for_engine(&engine_name)`. 3. Default engine to `"whisper"`, default model id to `default_model_id_for_engine(&engine_name)`.
4. `ensure_model_loaded(state, engine, model_id, None)` — None = no sequential-GPU guard. 4. `ensure_model_loaded(state, engine, model_id, None)` — None = no sequential-GPU guard.
5. Probe audio duration via `magnotia_audio::probe_audio_duration_secs`. If > 2 hours, return a friendly error. 5. Probe audio duration via `lumotia_audio::probe_audio_duration_secs`. If > 2 hours, return a friendly error.
6. `spawn_blocking` decodes the file (`decode_audio_file_limited(path, Some(MAX_FILE_TRANSCRIPTION_SECS))`), resamples to 16 kHz mono, then runs `transcribe_samples_sync`. 6. `spawn_blocking` decodes the file (`decode_audio_file_limited(path, Some(MAX_FILE_TRANSCRIPTION_SECS))`), resamples to 16 kHz mono, then runs `transcribe_samples_sync`.
7. Run `post_process_segments`. 7. Run `post_process_segments`.
8. Return a JSON value with `engine`, `modelId`, `segments`, `language`, `duration`, `inference_ms`, `raw_text`. 8. Return a JSON value with `engine`, `modelId`, `segments`, `language`, `duration`, `inference_ms`, `raw_text`.

View File

@@ -25,7 +25,7 @@ last_verified: 2026/05/09
- `delete_transcript(state, id) -> Result<(), String>`. - `delete_transcript(state, id) -> Result<(), String>`.
- `search_transcripts(state, query) -> Result<Vec<TranscriptDto>, String>` — FTS5; up to 50 best-rank. - `search_transcripts(state, query) -> Result<Vec<TranscriptDto>, String>` — FTS5; up to 50 best-rank.
- Events emitted: none. - Events emitted: none.
- Depends on: `magnotia_storage::{insert_transcript, list_transcripts_paged, count_transcripts, get_transcript, update_transcript, update_transcript_meta, delete_transcript, search_transcripts, InsertTranscriptParams, TranscriptRow, DEFAULT_PROFILE_ID}`. - Depends on: `lumotia_storage::{insert_transcript, list_transcripts_paged, count_transcripts, get_transcript, update_transcript, update_transcript_meta, delete_transcript, search_transcripts, InsertTranscriptParams, TranscriptRow, DEFAULT_PROFILE_ID}`.
- Called from frontend at: History page (list / search / get / update / delete), dictation result panel (`add_transcript`), Settings → About (`count_transcripts_command`), History viewer window (`update_transcript_meta_cmd` for star / template / language / llm_tags). - Called from frontend at: History page (list / search / get / update / delete), dictation result panel (`add_transcript`), Settings → About (`count_transcripts_command`), History viewer window (`update_transcript_meta_cmd` for star / template / language / llm_tags).
## What's in here ## What's in here
@@ -44,7 +44,7 @@ Patch shape for Task 2.5 / Phase 9 metadata: each field is `Option`. `None` pres
### Commands ### Commands
- `add_transcript` (`:103`) builds an `InsertTranscriptParams` from the wide request and calls `magnotia_storage::insert_transcript`. The FTS5 index is updated automatically by an SQLite trigger inside the storage crate. - `add_transcript` (`:103`) builds an `InsertTranscriptParams` from the wide request and calls `lumotia_storage::insert_transcript`. The FTS5 index is updated automatically by an SQLite trigger inside the storage crate.
- `list_transcripts` (`:135`) — paginated with sane defaults. - `list_transcripts` (`:135`) — paginated with sane defaults.
- `count_transcripts_command` (`:150`). - `count_transcripts_command` (`:150`).
- `get_transcript` (`:157`). - `get_transcript` (`:157`).

View File

@@ -85,8 +85,8 @@ frontend invoke('tts_stop')
- **Linux `spd-say` is non-blocking** — `tts_stop` cannot kill its synthesis once it has handed off to speech-dispatcher. The `stop_linux` extra call asks speech-dispatcher to flush its own queue, but that's a soft-stop, not a hard kill. - **Linux `spd-say` is non-blocking** — `tts_stop` cannot kill its synthesis once it has handed off to speech-dispatcher. The `stop_linux` extra call asks speech-dispatcher to flush its own queue, but that's a soft-stop, not a hard kill.
- **Windows path is heavy.** Every speak-call spawns a PowerShell. Acceptable for one-off use; for a streaming TTS pattern you'd want to keep a long-lived child or use the `windows` crate's SAPI bindings directly. - **Windows path is heavy.** Every speak-call spawns a PowerShell. Acceptable for one-off use; for a streaming TTS pattern you'd want to keep a long-lived child or use the `windows` crate's SAPI bindings directly.
- **Voice id semantics differ per platform.** macOS uses the voice name; Linux uses an spd-say `-t` token; Windows uses the SAPI registered voice token. Frontend treats them as opaque strings, but a saved-voice in Settings will not survive a platform switch. - **Voice id semantics differ per platform.** macOS uses the voice name; Linux uses an spd-say `-t` token; Windows uses the SAPI registered voice token. Frontend treats them as opaque strings, but a saved-voice in Settings will not survive a platform switch.
- **Brand consistency.** `Magnotia` is being renamed to `Lumenote` (per personal memory `project_lumenote_naming.md`). The TTS module currently embeds the string `"magnotia LLM cleanup"` and `"magnotia"`-prefixed temp filenames; rebrand sweep follow-up. - **Brand consistency.** `Lumotia` is being renamed to `Lumenote` (per personal memory `project_lumenote_naming.md`). The TTS module currently embeds the string `"lumotia LLM cleanup"` and `"lumotia"`-prefixed temp filenames; rebrand sweep follow-up.
- **No power assertion.** Long read-aloud sessions on macOS could be idled by App Nap. Add `PowerAssertion::begin("magnotia TTS")` to `tts_speak` if longer transcripts ever become a primary use case. - **No power assertion.** Long read-aloud sessions on macOS could be idled by App Nap. Add `PowerAssertion::begin("lumotia TTS")` to `tts_speak` if longer transcripts ever become a primary use case.
## See also ## See also

View File

@@ -17,9 +17,9 @@ last_verified: 2026/05/09
- LOC: 73. - LOC: 73.
- Compile gate: `#[cfg(not(target_os = "android"))]` — Android has no tray surface (declared at the `mod tray` line in `src-tauri/src/lib.rs:5`). - Compile gate: `#[cfg(not(target_os = "android"))]` — Android has no tray surface (declared at the `mod tray` line in `src-tauri/src/lib.rs:5`).
- Tauri commands exposed: none. The tray is set up imperatively from `lib.rs::run` setup hook. - Tauri commands exposed: none. The tray is set up imperatively from `lib.rs::run` setup hook.
- Events emitted: `magnotia:open-wind-down` (no payload) when the user clicks the wind-down menu item (`src-tauri/src/tray.rs:51`). - Events emitted: `lumotia:open-wind-down` (no payload) when the user clicks the wind-down menu item (`src-tauri/src/tray.rs:51`).
- Depends on: `tauri::image::Image`, `tauri::menu::{MenuBuilder, MenuItemBuilder}`, `tauri::tray::TrayIconBuilder`, `tauri::{Emitter, Manager}`. No workspace crates. - Depends on: `tauri::image::Image`, `tauri::menu::{MenuBuilder, MenuItemBuilder}`, `tauri::tray::TrayIconBuilder`, `tauri::{Emitter, Manager}`. No workspace crates.
- Called from frontend at: the frontend layout listens for `magnotia:open-wind-down` and routes to the wind-down page. - Called from frontend at: the frontend layout listens for `lumotia:open-wind-down` and routes to the wind-down page.
## What's in here ## What's in here
@@ -31,22 +31,22 @@ Wires three handlers:
- `on_menu_event` (`src-tauri/src/tray.rs:37`): - `on_menu_event` (`src-tauri/src/tray.rs:37`):
- `show``window.show(); window.set_focus();` - `show``window.show(); window.set_focus();`
- `wind-down` → show + focus + emit `magnotia:open-wind-down`. - `wind-down` → show + focus + emit `lumotia:open-wind-down`.
- `quit``app.exit(0)`. - `quit``app.exit(0)`.
- All other ids fall through. - All other ids fall through.
- `on_tray_icon_event` (`src-tauri/src/tray.rs:58`): a left-click brings the main window forward. Right-click is left to the platform default (which opens the menu). - `on_tray_icon_event` (`src-tauri/src/tray.rs:58`): a left-click brings the main window forward. Right-click is left to the platform default (which opens the menu).
The tray icon's tooltip is `"Magnotia — Ready"`. The status menu item has label `"Ready"` and is disabled (the `enabled(false)` builder call leaves it visible but unclickable). The tray icon's tooltip is `"Lumotia — Ready"`. The status menu item has label `"Ready"` and is disabled (the `enabled(false)` builder call leaves it visible but unclickable).
## Data flow ## Data flow
Out only: clicks on the tray menu either hide/show the window directly or fire one event to the frontend (`magnotia:open-wind-down`). The tray does not read any state. Out only: clicks on the tray menu either hide/show the window directly or fire one event to the frontend (`lumotia:open-wind-down`). The tray does not read any state.
## Watch-outs ## Watch-outs
- The status menu item is hard-coded "Ready". There is no wiring to update it dynamically as the engine moves between idle / loading / recording. If you want a live status, you'll need to retain a `TrayIcon` handle in `AppState` (or somewhere similar) so a command can call `set_tooltip` / update the menu item text. - The status menu item is hard-coded "Ready". There is no wiring to update it dynamically as the engine moves between idle / loading / recording. If you want a live status, you'll need to retain a `TrayIcon` handle in `AppState` (or somewhere similar) so a command can call `set_tooltip` / update the menu item text.
- The icon comes from `app.default_window_icon()` which is the packaged bundle icon (set in `tauri.conf.json` `bundle.icon`). Replacing the tray icon means re-running `tauri icon` or shipping a separate tray PNG. - The icon comes from `app.default_window_icon()` which is the packaged bundle icon (set in `tauri.conf.json` `bundle.icon`). Replacing the tray icon means re-running `tauri icon` or shipping a separate tray PNG.
- The `magnotia:open-wind-down` event payload is `()` — the frontend just needs to know "navigate to the wind-down page", and the page itself decides whether to render the ritual or a "you have not enabled this yet" stub. - The `lumotia:open-wind-down` event payload is `()` — the frontend just needs to know "navigate to the wind-down page", and the page itself decides whether to render the ritual or a "you have not enabled this yet" stub.
- Close-to-tray (intercepting `WindowEvent::CloseRequested`) lives in `lib.rs::run` setup hook (`src-tauri/src/lib.rs:282`), not here. The two halves are split because the close-to-tray handler needs the cloned `WebviewWindow`. - Close-to-tray (intercepting `WindowEvent::CloseRequested`) lives in `lib.rs::run` setup hook (`src-tauri/src/lib.rs:282`), not here. The two halves are split because the close-to-tray handler needs the cloned `WebviewWindow`.
## See also ## See also

View File

@@ -14,7 +14,7 @@ last_verified: 2026/05/09
## At a glance ## At a glance
- Paths: `src-tauri/tauri.conf.json` (43 LOC), `src-tauri/tauri.linux.conf.json` (17 LOC). - Paths: `src-tauri/tauri.conf.json` (43 LOC), `src-tauri/tauri.linux.conf.json` (17 LOC).
- Identifier: `uk.co.corbel.magnotia`. - Identifier: `uk.co.corbel.lumotia`.
- Tauri version targeted: schema `https://schema.tauri.app/config/2`. - Tauri version targeted: schema `https://schema.tauri.app/config/2`.
- Main window labels: `main` (defined here), plus `tasks-float`, `transcript-viewer`, `transcription-preview` (built imperatively from `commands::windows`). - Main window labels: `main` (defined here), plus `tasks-float`, `transcript-viewer`, `transcription-preview` (built imperatively from `commands::windows`).
- Frontend: `npm run dev:frontend` for dev (port 1420), `npm run build` produces `../build` for release. - Frontend: `npm run dev:frontend` for dev (port 1420), `npm run build` produces `../build` for release.
@@ -28,9 +28,9 @@ last_verified: 2026/05/09
### `tauri.conf.json` ### `tauri.conf.json`
``` ```
productName: "Magnotia" productName: "Lumotia"
version: "0.1.0" version: "0.1.0"
identifier: "uk.co.corbel.magnotia" identifier: "uk.co.corbel.lumotia"
``` ```
#### `build` #### `build`
@@ -44,7 +44,7 @@ frontendDist: "../build"
#### `app.windows[0]` #### `app.windows[0]`
The main window. Title `"Magnotia"`, 1020×720 (min 960×600), centred, resizable, **frameless** (`decorations: false`). The Linux overlay flips this to `decorations: true`. The main window. Title `"Lumotia"`, 1020×720 (min 960×600), centred, resizable, **frameless** (`decorations: false`). The Linux overlay flips this to `decorations: true`.
#### `app.security.csp` #### `app.security.csp`

Some files were not shown because too many files have changed in this diff Show More