Compare commits
130 Commits
pre-consol
...
4cb954ece4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cb954ece4 | ||
|
|
fdf27db0a1 | ||
| 2dfd7167fd | |||
| aecb191705 | |||
| f4c0635549 | |||
| 736896c2b8 | |||
| 9b17425bc9 | |||
| d3554951e0 | |||
| 47b005ad70 | |||
| ca3a55320b | |||
| 6da15d1ac8 | |||
| 9ba7babcb0 | |||
| 5778696140 | |||
| d2f64a231d | |||
| ec09f8ffdb | |||
| aa1ba48e07 | |||
| 3a27031eba | |||
| aebf6cd149 | |||
| 5e3a9f9f42 | |||
| 13af425dfa | |||
| a4cca58289 | |||
| e857a814ad | |||
| 360e5457dc | |||
| 637ed89eac | |||
| 7a21d0c7fc | |||
| a545c7a12d | |||
| f274611d23 | |||
| fdeda6c642 | |||
| 2f85ec5806 | |||
| d234ef394e | |||
| 0f105f0e15 | |||
| 699cb7e08e | |||
| c42a144aad | |||
| b410c6196b | |||
| 10480ccbc7 | |||
| 16cbd7c13c | |||
| 4e16dbefb4 | |||
| 548cb3889b | |||
| 211f576ebd | |||
| 3e9739db1e | |||
|
|
7ff7295567 | ||
|
|
0b6be94f3e | ||
|
|
89c63891fa | ||
| 749403697a | |||
| eb3d2f90ab | |||
| 6469663c25 | |||
| 22d554e85b | |||
| fa734c869f | |||
| d089fdb37f | |||
| 2da0a5bab8 | |||
|
|
be5a7146ca | ||
| 150059e174 | |||
|
|
17f4dff791 | ||
|
|
4abc2356c2 | ||
|
|
bd16c118cc | ||
|
|
73f8c45f86 | ||
|
|
d5d751c9ad | ||
|
|
a7fdc96e7b | ||
|
|
581a098508 | ||
|
|
c04c719d48 | ||
|
|
38da407942 | ||
|
|
fd48f55edb | ||
|
|
41be27b410 | ||
|
|
ab3bb9370c | ||
|
|
3d568148b8 | ||
|
|
f3fd86185e | ||
|
|
90f4d9b0fb | ||
|
|
dfa6457f1f | ||
| cc77befda8 | |||
| 0ca4e0ef9e | |||
| dd45f10cd4 | |||
| 3bc34d2873 | |||
| 6269aab0d2 | |||
| 7fc971df05 | |||
| 7eb52d97b1 | |||
| 489c066a70 | |||
| ef42c95000 | |||
| 7567bede52 | |||
| 1b6ad88ead | |||
| c26d82c26a | |||
| eb6e291191 | |||
| d1500cda8c | |||
| bfec88ccc9 | |||
| 5a15c931d0 | |||
| 3eb24f2c63 | |||
| 48d3db7395 | |||
| 49a795f533 | |||
| 512c9f7a19 | |||
| d8011bbe7c | |||
| fa93033165 | |||
| c29720e145 | |||
| 3cadbb0f82 | |||
| 54ddd41265 | |||
| 4ffdae9838 | |||
| cb32285ce0 | |||
| 42b423e4f4 | |||
| 83bd338aff | |||
| 839754f4ee | |||
| b992967e50 | |||
| 92b32282d9 | |||
| 729b82cf50 | |||
| d5eb212246 | |||
| 2cc0697de9 | |||
| 6cd1c22c0f | |||
| eebea8cb9a | |||
| b333c6229e | |||
| 55b34d8ffc | |||
| 3cf3e41899 | |||
| 9f53702c7e | |||
| b344e8a580 | |||
| 1d4f1070a2 | |||
| a327f4d882 | |||
| d307722c7a | |||
| 46be0a5aca | |||
| f25f8db818 | |||
| bbc7c217be | |||
| 0c34a29367 | |||
| df6b19834d | |||
| 420da679f9 | |||
| 2b82b9be5b | |||
| 0e18a78fae | |||
| 4700668df1 | |||
| 4e947dec21 | |||
| 509b983c09 | |||
| 0b1c492edd | |||
| 6579c5fb6a | |||
| fe61661305 | |||
|
|
f8c9769e04 | ||
|
|
becbf69c35 | ||
|
|
b8b953dfa8 |
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@@ -131,7 +131,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: .
|
||||
shared-key: kon-build-${{ matrix.os }}
|
||||
shared-key: magnotia-build-${{ matrix.os }}
|
||||
|
||||
- name: Install JS deps
|
||||
run: npm ci
|
||||
@@ -165,7 +165,17 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: kon-${{ matrix.os }}-${{ github.sha }}
|
||||
name: magnotia-${{ matrix.os }}-${{ github.sha }}
|
||||
path: ${{ matrix.artifact_glob }}
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Report artifact sizes
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -d src-tauri/target/release/bundle ]; then
|
||||
find src-tauri/target/release/bundle -type f \
|
||||
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.msi' -o -name '*.exe' -o -name '*.dmg' -o -name '*.app' \) \
|
||||
-exec du -h {} + | sort -h
|
||||
fi
|
||||
|
||||
19
.github/workflows/check.yml
vendored
19
.github/workflows/check.yml
vendored
@@ -111,6 +111,8 @@ jobs:
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
# Cache the Cargo target dir + registry per OS so the heavy
|
||||
# whisper-rs-sys C++ build only happens on a clean cache.
|
||||
@@ -123,17 +125,29 @@ jobs:
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: .
|
||||
shared-key: kon-${{ matrix.os }}
|
||||
shared-key: magnotia-${{ matrix.os }}
|
||||
|
||||
- name: cargo check (workspace)
|
||||
run: cargo check --workspace --all-targets
|
||||
|
||||
- name: cargo fmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# Library tests only — no runtime/GPU deps. Linux-gated to keep
|
||||
# the macOS + Windows legs focused on compile coverage.
|
||||
- name: cargo test (workspace, libs)
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
run: cargo test --workspace --lib
|
||||
|
||||
- name: cargo audit
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
run: |
|
||||
cargo install cargo-audit --locked
|
||||
cargo audit
|
||||
|
||||
frontend:
|
||||
name: svelte build + lint
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -150,6 +164,9 @@ jobs:
|
||||
- name: Install JS deps
|
||||
run: npm ci
|
||||
|
||||
- name: npm audit
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
# `tauri build` inside check.yml would trigger the full Rust build
|
||||
# which is owned by the rust job. Here we only validate that the
|
||||
# Svelte/Vite frontend compiles cleanly.
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,7 +3,6 @@ target/
|
||||
build/
|
||||
dist/
|
||||
.svelte-kit/
|
||||
Cargo.lock
|
||||
.firecrawl/
|
||||
.worktrees/
|
||||
.cargo/
|
||||
|
||||
7895
Cargo.lock
generated
Normal file
7895
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,10 @@
|
||||
[workspace]
|
||||
members = ["src-tauri", "crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
163
HANDOVER.md
163
HANDOVER.md
@@ -1,97 +1,116 @@
|
||||
---
|
||||
name: handover-2026-04-19
|
||||
name: handover-2026-04-25
|
||||
type: reference
|
||||
tags: [handover, session, kon]
|
||||
description: Session handover — 2026/04/19 dogfood polish + cross-platform window chrome
|
||||
tags: [handover, session, magnotia, phase-9, polish-debt]
|
||||
description: Session handover — 2026/04/24-25 Phase 9 polish debt mostly shipped
|
||||
---
|
||||
|
||||
# Kon Handover — 2026/04/19
|
||||
# Magnotia Handover — 2026/04/25
|
||||
|
||||
Second dogfood sprint. Four phases: (1) fix bugs surfaced on first real use, (2) redesign History for cognitive-load hygiene, (3) resolve broken window resize/drag on Linux Wayland, (4) clean up microphone picker.
|
||||
Phase 9 session. Spec + plan written from scratch and committed; plan corrections layered in after critical review against the actual codebase (Codex was unreachable for cross-model review, three retries failed at the ChatGPT-account-entitlement layer). Sub-phases 9a + 9b + sparkline polish landed end to end. Sub-phase 9c reduced to the Phase 8 carryover bug fix; sub-phase 9d's walkthrough sweeps deferred to Phase 10a QC.
|
||||
|
||||
## 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`.
|
||||
|
||||
## What shipped this session
|
||||
|
||||
### Cross-window preferences sync
|
||||
- `preferences.svelte.js` emits `kon:preferences-changed` Tauri event on update.
|
||||
- Main / viewer / float layouts listen and call `applyExternalPreferences` without re-emit, so theme and font changes propagate live across sibling windows.
|
||||
- Echo suppressed via source window label check.
|
||||
### 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.
|
||||
- `src/lib/utils/saveMarkdown.ts` utility centralises `suggestedFilename`, `saveTranscriptAsMarkdown`, `exportTranscriptsToDir` (directory-mode bulk export with in-batch collision suffixing).
|
||||
- HistoryPage `exportMarkdown` no longer copies to clipboard; it opens the OS save dialog and writes the file. Cancel returns silently.
|
||||
- HistoryPage gained a slim leading checkbox per row, a bulk-action toolbar (select-all / clear / export / delete), `Esc` to clear, `Cmd/Ctrl+A` to select-all-visible when focus is inside the list and not in a text input.
|
||||
|
||||
### Hotkey recorder
|
||||
- Root cause of "can't change hotkey": button-level `onkeydown` relied on post-click keyboard focus, which webkit2gtk on Linux does not guarantee.
|
||||
- Fix: `document.addEventListener("keydown", ..., { capture: true })` inside a `$effect` gated by `recording`. Beats any descendant handler. Escape now cancels.
|
||||
### 9b — LLM content tags
|
||||
- `magnotia-llm` exports a new `ContentTags { topic, intent }`, an `INTENT_CLOSED_SET`, an `is_valid_intent` helper, a `CONTENT_TAGS_SYSTEM` prompt and a `CONTENT_TAGS_GRAMMAR` GBNF (recursive style matching the existing `TASK_ARRAY_GRAMMAR`).
|
||||
- `LlmEngine::extract_content_tags` method follows the same render-chat → generate → JSON-parse shape as the existing `cleanup_text` and `extract_tasks`. Truncates to the trailing 2000 chars on a UTF-8 boundary; max_tokens 96 is enough for the JSON envelope. Smoke test in `crates/llm/tests/content_tags_smoke.rs` is gated on `MAGNOTIA_LLM_TEST_MODEL` matching the Phase 8 pattern.
|
||||
- `extract_content_tags_cmd` Tauri wrapper bridges through `state.llm_engine` with the standard `spawn_blocking` + `PowerAssertion` guard.
|
||||
|
||||
### History page redesign (research-backed)
|
||||
- Compact row now shows the **title** (or "Untitled"), not body-preview text — metadata already lives in the row columns (date, duration, source icon).
|
||||
- Expanded row gets an inline title input (replaces the old Rename prompt modal).
|
||||
- **Edit** button opens the viewer window in `edit` mode (editable textarea, debounced save to localStorage + storage-event sync back to main history).
|
||||
- **Export .md** copies a full YAML-frontmatter markdown document to the clipboard — paste into Obsidian.
|
||||
- **Tags**: `$lib/utils/frontmatter.js` exposes `deriveAutoTags` (currently returns `[]`), `buildFrontmatter`, `serialiseFrontmatter`, `buildMarkdown`. Manual tags stored as `item.manualTags`, rendered as removable chips in the expanded row with `+ add tag` input.
|
||||
- Header tag chip bar (cap 7, click to filter, × to clear), plus `tag:xyz` search syntax.
|
||||
- Global **Starred** filter toggle in the History header.
|
||||
- Research memo found all five previous auto-tag families redundant with existing row UI — kept the derivation hook for the post-Task-7 `topic:*` content tag from kon-llm.
|
||||
- Duplicate-transcript render fix: expanded `<p>` only if compact preview actually truncated.
|
||||
### 9b structural — migration v14 + persistence wiring
|
||||
A correction layered in after the critical-review pass discovered the original Task 9 was assuming a writable `saveHistory()` path that turned out to be a no-op stub.
|
||||
- Migration v14 adds `transcripts.llm_tags TEXT NOT NULL DEFAULT ''`.
|
||||
- `magnotia-storage` `database.rs` SELECT statements include the column. `TranscriptRow` + `transcript_row_from` carry it. `update_transcript_meta` accepts an `Option<&str>` for `llm_tags` (sixth optional, `#[allow(too_many_arguments)]` keeps clippy happy without inverting the signature into a struct).
|
||||
- `commands/transcripts.rs` `TranscriptDto` + `UpdateTranscriptMetaRequest` add `llm_tags`; `update_transcript_meta_cmd` forwards.
|
||||
- Frontend types: `TranscriptEntry.llmTags: string[]`, `TranscriptRow.llmTags: string`, `ContentTags`, optional `TranscriptMetaPatch.llmTags`.
|
||||
- `mapTranscriptRow` hydrates `llmTags`. `saveTranscriptMeta` now also forwards `llmTags` payloads. `buildFrontmatter` unions auto + manual + LLM tags into the exported markdown frontmatter.
|
||||
- HistoryPage tag UI: per-row "Tag" button, dashed-italic LLM chips that promote-to-manual on click, top-toolbar "Tag all untagged" with progress text. Existing `addManualTag` / `removeManualTag` handlers swap their no-op `saveHistory()` calls for `saveTranscriptMeta` — picks up the latent `manualTags` persistence bug as a side effect.
|
||||
|
||||
### Viewer / editor popout
|
||||
- `/viewer` route now reads `kon_viewer_mode` from localStorage ("view" | "edit").
|
||||
- Edit mode renders a plain textarea bound to `item.text`; 400ms debounced save flushes on input, final flush on `onDestroy`. Segment-specific controls (Compact, Starred) hidden in edit mode.
|
||||
- Native title: **"Kon - Transcription Editor"**.
|
||||
### 9b incidental fix — Phase 8 brittle test
|
||||
`list_recent_completions_uses_local_day_boundary` failed today because its UTC-anchored `'-2 days', '+12 hours'` offset drifts across UTC midnight relative to the local-day spine the query uses. Fixed by anchoring the timestamp to the local date 2 days ago directly: `datetime(DATE('now', 'localtime', '-2 days') || ' 12:00:00')`. Phase 9 was not the cause; the test happened to fail on today's clock.
|
||||
|
||||
### Platform-aware window chrome (Linux fix)
|
||||
**Root cause:** Tauri v2 frameless `decorations: false` on KDE Wayland + webkit2gtk does not honour diagonal corner resize (collapses `NorthEast` etc. to a single axis via GTK's `gtk_window_begin_resize_drag`), and `data-tauri-drag-region` adds noticeable drag latency. Setting `setPointerCapture` ahead of `startResizeDragging` does not help once the compositor has taken over the pointer grab. Verified via Context7 docs + Codex diagnosis — Linux frameless is a known-fragile path.
|
||||
### 9c — Settings (scaled down)
|
||||
- `SettingsGroup.svelte` reusable progressive-disclosure wrapper landed (animated chevron, hover, focus-visible, prefers-reduced-motion).
|
||||
- Sparkline toggle (Phase 8 carryover backlog) relocated from the Rituals section into a new dedicated "Tasks" section. Closes the Phase 8 review note that the toggle was visually claimed by the launch-at-login subgroup.
|
||||
- **Deferred:** the deeper restructure to seven progressive-disclosure groups + search box. The 2309-line `SettingsPage.svelte` uses a hand-rolled accordion that needs careful unwinding; full restructure was too invasive to land safely in this session. `SettingsGroup` component is in tree, ready for that follow-up pass.
|
||||
|
||||
**Fix:**
|
||||
- Linux uses **native KWin/Mutter decorations**. `src-tauri/tauri.linux.conf.json` overlays `decorations: true` + full main window config (title, sizes) — overlays **replace** the windows array, so every field must be present, not just the delta. `src-tauri/src/commands/windows.rs` uses `cfg!(target_os = "linux")` to set decorations per window.
|
||||
- macOS / Windows keep custom chrome. `src/lib/utils/osInfo.js` `isLinux()` gates `<Titlebar>` and `<ResizeHandles>` via `useCustomChrome = $state(false)`; flips to `!isLinux()` after `loadOsInfo()` resolves.
|
||||
- Dueling drag-region handlers removed across Titlebar, float page, viewer page — everywhere a manual `startDragging()` lives, the `data-tauri-drag-region` attribute was deleted (they're alternatives per Tauri docs, not combinable).
|
||||
- `ResizeHandles` kept for macOS/Windows frameless: 12 px edges / 20 px corners via CSS vars (`--kon-resize-edge`, `--kon-resize-corner`), `pointerdown` + `setPointerCapture`, corners with explicit higher z-index. Handles rendered as siblings of the animated layout div so `position: fixed` is viewport-relative rather than captured by the transform containing block.
|
||||
### 9d — Polish (partial)
|
||||
- `CompletionSparkline.svelte`: friendlier sentence-form aria-label ("3 completed today. 14 total over the last 7 days." rather than a bare numeric list), per-bar `<title>` tooltips with absolute date + count, 30 ms staggered scaleY entrance animation. Earlier draft `tabindex=0` on the SVG removed: `role="img"` + aria-label is sufficient for SR navigation without putting it in the keyboard tab order (svelte-check's `noninteractive_tabindex` warning, correctly).
|
||||
- TasksPage badge: 180 ms opacity + translate-Y entrance animation on conditional mount. Both new animations respect `prefers-reduced-motion`.
|
||||
- **Deferred to Phase 10a QC:** keyboard traversal walkthrough across every page, focus-visible ring sweep, WCAG AA contrast audit in both themes, dark-mode parity check, icon-only-button aria-label audit. These are walkthrough-driven and need a running dev server to validate.
|
||||
|
||||
### Window minimum sizes (evidence-backed)
|
||||
Research pass cited GNOME HIG (1024×600 desktop / 360×294 mobile floors), WCAG 2.2 SC 1.4.10 Reflow (320 CSS px), Raycast 750×474 as a reference for single-pane working width, and consistent A11y principle that nothing should clip in the default configuration.
|
||||
## Verification state at session end
|
||||
|
||||
| Window | Was | Now | Rationale |
|
||||
|---|---|---|---|
|
||||
| Main | 1020×540 | **960×600** | Fits 210 px sidebar + ~750 px content; GNOME vertical floor. |
|
||||
| Float | 400×400 | **360×480** | 360 = GNOME mobile floor; 480 fits pills + quick-add + sort + ~6 task rows without scroll. |
|
||||
| Transcript editor | 450×500 | **560×520** | Exceeds WCAG reflow floor; ~60-70 char measure for editing. |
|
||||
Fresh run on `main` tip `dd45f10`:
|
||||
|
||||
### Microphone picker cleanup
|
||||
- ALSA enumeration was leaking `hw:`, `plughw:`, `front:`, `sysdefault:`, `null` et al into the dropdown.
|
||||
- `SettingsPage.svelte` now renders only sentinel devices (`default`, `pipewire`, `pulse`) + one entry per unique sound card, keyed off the `sysdefault:CARD=X` alias.
|
||||
- `crates/audio/src/capture.rs` reads `/proc/asound/cards` and populates a new `description` field on `DeviceInfo` with the card's full product string (e.g. "Blue Microphones" for Jake's Yeti). Frontend prefers description → CARD=X short name → raw name.
|
||||
- `cargo fmt --check`: clean.
|
||||
- `cargo clippy --all-targets -- -D warnings`: clean.
|
||||
- `cargo test`: **277 tests pass**, 0 failed. Storage gained 1 new test (`update_transcript_meta_writes_llm_tags`), magnotia-tauri gained 2 (write_text_file). The Phase 8 brittle test fix is in this count.
|
||||
- `npm run check`: 0 errors, 0 warnings across 3957 files.
|
||||
- `npm run build`: clean production build via `@sveltejs/adapter-static`.
|
||||
|
||||
### GPU reporting
|
||||
- `commands/models.rs::get_runtime_capabilities` was hardcoded to `accelerators: vec!["cpu"]` and `supports_gpu: false` for whisper. Updated to `["cpu", "vulkan"]` and whisper `supports_gpu: true`, reflecting that `crates/transcription/Cargo.toml` links transcribe-rs with the `whisper-vulkan` feature unconditionally.
|
||||
- Settings now shows the Vulkan option instead of the "This build is CPU-only" notice.
|
||||
## Plan correction summary (for any future reader)
|
||||
|
||||
### Desktop shortcut
|
||||
- `~/Desktop/Kon.desktop` launcher with the 128×128 icon, `Terminal=true` so logs are visible and Ctrl+C cleanly stops the run.sh wrapper.
|
||||
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`:
|
||||
|
||||
## What's deferred
|
||||
1. `magnotia-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`.
|
||||
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.
|
||||
|
||||
- **Transparent windows (`transparent: true`)** — Tauri issue #13270 reports this smooths drag/resize further on Linux, but it's moot now that Linux uses native decorations.
|
||||
- **File-system export (.md save dialog)** — currently clipboard-only. Needs a Rust `write_text_file` command for plugin-less file writes.
|
||||
- **Bulk select + bulk export** in History.
|
||||
- **LLM-powered content tags** (`topic:*`, `intent:*`) — slots into Task 7 `kon-llm` stub once Phase 3 wires real llama-cpp-2.
|
||||
- **Settings UX overhaul** — Jake flagged that current settings feel overwhelming. Proposed: bunch high-traffic settings, hide advanced behind a toggle. Brainstorm + plan deferred to a dedicated session.
|
||||
- **Task 7 (MicroSteps end-to-end)** — storage + Tauri CRUD + kon-llm stub + frontend dual-write all landed in an earlier commit chain. The MicroSteps UI was written as the final task 7 step but not yet dogfooded against the stub LLM. Needs manual walkthrough.
|
||||
## Owed to Jake (next session)
|
||||
|
||||
## Gotchas discovered today
|
||||
1. **Manual dogfood walkthrough.** Cannot be driven by an automated agent. When opening Magnotia next:
|
||||
- Export one transcript via the History "Export .md" button — save dialog opens, file written to chosen path. Cancel — no toast, no fallback.
|
||||
- Select 3 history rows via checkboxes — toolbar surfaces, "Export selected" writes one .md per row to a chosen folder, collisions suffixed " (2)" etc.
|
||||
- Click "Tag" on one row — within a few seconds, dashed `topic:*` and `intent:*` chips appear. Click a chip — it moves into `manualTags` (solid accent chip). Page refresh — both `manualTags` and `llmTags` survive (this is the persistence-fix outcome).
|
||||
- "Tag all untagged" runs across the corpus, progress text updates, success toast at the end.
|
||||
- Settings → new "Tasks" section appears with the sparkline toggle. Toggle off → sparkline disappears on Tasks page; badge stays. Toggle on → sparkline returns.
|
||||
- Sparkline keyboard-focus-or-hover on a bar shows the date + count tooltip. Screen reader announces the sentence-form summary.
|
||||
- `prefers-reduced-motion` set in OS — badge entrance + sparkline stagger both stop.
|
||||
|
||||
| Issue | Fix |
|
||||
2. **Phase 9 follow-up to absorb in a future polish session:**
|
||||
- Full `SettingsPage` regroup using `SettingsGroup` (already in tree), search box, Start-here always-expanded, six collapsed groups by domain.
|
||||
- The walkthrough-driven a11y sweeps from Phase 9 Tasks 14-15. Phase 10a QC will catch most; document any issues for a follow-up polish commit.
|
||||
|
||||
3. **Codex unavailability.** Three retries on the codex-rescue subagent failed because the local `~/.codex/config.toml` pins `model = "gpt-5.5"` which the ChatGPT account doesn't have access to, and explicit overrides (`gpt-4o`, `o4-mini`, `codex-mini-latest`, `gpt-5.3-codex-spark`) are also blocked at the ChatGPT-account level. Either upgrade the ChatGPT plan tier or switch Codex auth to an OpenAI API key (`codex login` with key) to unblock cross-model review on future plans.
|
||||
|
||||
## What's left for v0.1
|
||||
|
||||
| Phase | State |
|
||||
|---|---|
|
||||
| `tauri.linux.conf.json` stripped title and min sizes from main window | Overlay **replaces** the windows array — include every field, not just the delta |
|
||||
| `data-tauri-drag-region` + manual `startDragging()` on the same node caused drag latency | Pick one — we use manual `startDragging` for the button/input early-return logic |
|
||||
| Corner resize collapsed to single axis on KWin Wayland | Native decorations on Linux side-step the whole frameless path |
|
||||
| `animate-float-enter` on the viewer/float layout root created a containing block that broke `position: fixed` on ResizeHandles children | Render ResizeHandles as a sibling of the animated div, not a descendant |
|
||||
| Kon binary auto-respawned on file-save while a second run.sh was also launching → two visible instances sharing one Vite server | Do not script `./run.sh` while the user has already launched via the desktop icon; rely on HMR |
|
||||
| `run.sh` leaves `"beforeDevCommand": ""` in tauri.conf.json if its cleanup trap is bypassed (e.g. SIGKILL) | Cleanup trap restores `"npm run dev"` on graceful exit; SIGTERM (not SIGKILL) is the right kill signal |
|
||||
| `/proc/asound/cards` header lines have leading whitespace for 2-digit card ID alignment | Parser trims leading whitespace before checking for leading digit |
|
||||
| Phases 1-8 | All shipped. |
|
||||
| Phase 9 | **Mostly shipped this session.** Export plumbing, LLM content tags (with persistence), polish on sparkline + badge are live. SettingsPage deeper restructure + walkthrough a11y sweeps deferred. Roadmap entry updated. |
|
||||
| Phase 10a | QC: dogfood walkthrough (above), Rachmann's RB-08 Mac verification (parallel), cross-platform CI, a11y regression, clean-install test. Half day. |
|
||||
| Phase 10b | Magnotia → Magnotia rename sweep: package name, all 10 crates, bundle ids, install paths, `magnotia.db` → `magnotia.db`, event names, repo rename on both remotes. Half to 1 day. |
|
||||
| Phase 10c | Release: 0.1.0 version sync, CHANGELOG seeded from roadmap phases, release notes, tag + push. Half day. |
|
||||
|
||||
## How to resume
|
||||
### Release-blocker state
|
||||
|
||||
```
|
||||
Picking up Kon dogfooding from 2026/04/19.
|
||||
HANDOVER is at HANDOVER.md in the project root.
|
||||
Active priorities: (1) confirm resize/drag/mic cleanup, (2) Task 7 MicroSteps
|
||||
dogfood with kon-llm stub, (3) Settings UX brainstorm.
|
||||
```
|
||||
- **0 open CRITICAL.**
|
||||
- **1 open MAJOR.** RB-08 `power-assertion-macos-objc2` (awaits Rachmann's manual runtime verification). Gates v0.1 tagging.
|
||||
|
||||
## Repo state at session end
|
||||
|
||||
- `main` at `dd45f10`.
|
||||
- 18 Phase 9 commits (3 docs + 15 feat/polish) on top of yesterday's tip.
|
||||
- Local branches: `main` only.
|
||||
- `cargo build --workspace` green / `cargo test --workspace` green (277 passing) / `cargo clippy --workspace --all-targets -- -D warnings` clean / `cargo fmt --check` clean / `npm run check` 0/0 / `npm run build` clean.
|
||||
|
||||
## Anchors
|
||||
|
||||
- Spec: [docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md](docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md)
|
||||
- Plan: [docs/superpowers/plans/2026-04-24-phase9-polish-debt.md](docs/superpowers/plans/2026-04-24-phase9-polish-debt.md)
|
||||
- Roadmap: [docs/roadmap/2026-04-23-magnotia-feature-complete-roadmap.md](docs/roadmap/2026-04-23-magnotia-feature-complete-roadmap.md)
|
||||
- Previous handover: [HANDOVER-2026-04-24.md](HANDOVER-2026-04-24.md) (Phase 8)
|
||||
- Release-blocker index: [docs/issues/README.md](docs/issues/README.md)
|
||||
- Rebrand memory: `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_magnotia_rebrand.md`
|
||||
- Active-focus upstream: `context/active-focus.md` in CORBEL-Main
|
||||
|
||||
89
README.md
89
README.md
@@ -1,8 +1,8 @@
|
||||
# Kon
|
||||
# Magnotia
|
||||
|
||||
*Think out loud. Keep working.*
|
||||
|
||||
Kon 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ Kon is a local-first, cognitive-load-aware dictation and task-capture desktop ap
|
||||
**Pre-alpha.** Actively dogfooded on Linux (KDE Plasma 6 on Wayland). macOS and Windows targets are in scope and exercised by CI, but not yet beta-ready. One primary user; open source-intent with licence TBD before public beta.
|
||||
|
||||
- Current `main`: see commit log
|
||||
- 136 automated lib tests across 10 crates, all passing
|
||||
- 9 library crates plus the Tauri app crate; 220+ lib tests plus 67 Tauri-app tests, all passing
|
||||
- Cross-platform CI (Linux / macOS / Windows) via GitHub Actions
|
||||
|
||||
---
|
||||
@@ -20,15 +20,15 @@ Kon is a local-first, cognitive-load-aware dictation and task-capture desktop ap
|
||||
|
||||
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.
|
||||
3. **Composable, not monolithic.** Kon 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.** 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).
|
||||
4. **LLM scope is narrow.** The in-app LLM does transcription cleanup and task extraction. It is not a wake-word agent, not a chat UI, not a multi-provider cloud fan-out.
|
||||
5. **Raw transcript is always recoverable.** Cleanup is additive, never destructive. The user can always see and revert to what Whisper heard.
|
||||
|
||||
These are enforced in the codebase (where practical) and in the docs under [`docs/whisper-ecosystem/kon-context.md`](docs/whisper-ecosystem/kon-context.md).
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## What Kon does today
|
||||
## What Magnotia does today
|
||||
|
||||
### 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.
|
||||
@@ -41,7 +41,7 @@ These are enforced in the codebase (where practical) and in the docs under [`doc
|
||||
|
||||
### LLM formatting (local only)
|
||||
- Local LLM runtime via [llama-cpp-2](https://github.com/utilityai/llama-cpp-rs) 0.1.144 with Vulkan.
|
||||
- Three Qwen3 tiers (1.7B, 4B-Instruct-2507, 14B) auto-selected by hardware probe.
|
||||
- Four Qwen tiers (Qwen3.5 2B / 4B / 9B + Qwen3.6 27B) auto-selected by hardware probe.
|
||||
- GBNF grammar-constrained output for task extraction (always-parseable JSON).
|
||||
- System prompt hardened against voice-delivered prompt injection.
|
||||
|
||||
@@ -64,7 +64,7 @@ These are enforced in the codebase (where practical) and in the docs under [`doc
|
||||
- Transcript editor window (`/viewer`) with debounced autosave.
|
||||
|
||||
### External integration
|
||||
- **MCP stdio server** (`kon-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** (`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.
|
||||
|
||||
### Accessibility
|
||||
- Dyslexia-friendly fonts bundled: Lexend, Atkinson Hyperlegible Next, OpenDyslexic.
|
||||
@@ -83,7 +83,7 @@ These are enforced in the codebase (where practical) and in the docs under [`doc
|
||||
|
||||
## Architecture
|
||||
|
||||
Kon is a Tauri 2 desktop app with three layers:
|
||||
Magnotia is a Tauri 2 desktop app with three layers:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
@@ -92,29 +92,31 @@ Kon is a Tauri 2 desktop app with three layers:
|
||||
│ Stores, i18n, Tailwind CSS │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Tauri 2 runtime (src-tauri/) │
|
||||
│ Commands: audio, clipboard, diagnostics, hotkey, live, llm, │
|
||||
│ meeting, models, paste, power, profiles, tasks, │
|
||||
│ transcription, transcripts, update, windows │
|
||||
│ Commands: audio, clipboard, diagnostics, feedback, fs, │
|
||||
│ hardware, hotkey, intentions, live, llm, meeting, │
|
||||
│ models, nudges, paste, profiles, rituals, tasks, │
|
||||
│ transcription, transcripts, tts, update, windows │
|
||||
│ Utility modules (no commands): mod, power, security │
|
||||
│ Plugins: global-shortcut, dialog, opener, updater, │
|
||||
│ window-state │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Rust workspace (crates/) │
|
||||
│ kon-core, kon-audio, kon-transcription, kon-llm, │
|
||||
│ kon-ai-formatting, kon-storage, kon-hotkey, │
|
||||
│ kon-cloud-providers, kon-mcp │
|
||||
│ magnotia-core, magnotia-audio, magnotia-transcription, magnotia-llm, │
|
||||
│ magnotia-ai-formatting, magnotia-storage, magnotia-hotkey, │
|
||||
│ magnotia-cloud-providers, magnotia-mcp │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The Rust workspace is the brain; Tauri is the OS integration surface; Svelte is the UI. The MCP server (`kon-mcp`) is a separate binary that opens Kon's SQLite store read-only — it's Kon-as-primitive for external agents.
|
||||
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.
|
||||
|
||||
### Repository layout
|
||||
|
||||
```
|
||||
kon/
|
||||
magnotia/
|
||||
├── Cargo.toml # workspace root
|
||||
├── src-tauri/ # Tauri app (main binary + commands)
|
||||
│ ├── src/
|
||||
│ │ ├── commands/ # 18 Tauri command modules
|
||||
│ │ ├── commands/ # 22 Tauri command modules + 3 utility modules (`mod`, `power`, `security`)
|
||||
│ │ ├── lib.rs # app entry, setup, command registration
|
||||
│ │ ├── tray.rs
|
||||
│ │ └── main.rs
|
||||
@@ -162,15 +164,15 @@ kon/
|
||||
|
||||
| Crate | Responsibility |
|
||||
|---|---|
|
||||
| **`kon-core`** | Shared types (`Segment`, `Transcript`, `Megabytes`, `ModelId`), constants, the `Engine` / `SpeedTier` / `AccuracyTier` enums, hardware probe (`sysinfo`-based), model registry (Whisper + Parakeet + Moonshine entries), hardware-aware recommendation scoring, `process_watch` for meeting detection. |
|
||||
| **`kon-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. |
|
||||
| **`kon-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. |
|
||||
| **`kon-llm`** | `llama-cpp-2` engine with Qwen3 model manager. Three high-level surfaces: `cleanup_text` (formatting), `decompose_task` (3–7 micro-steps, GBNF-constrained JSON array), `extract_tasks` (optional-array, GBNF-constrained). Resumable HTTP downloads with SHA-256 verify. |
|
||||
| **`kon-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). |
|
||||
| **`kon-storage`** | SQLite via `sqlx` 0.8. Migrations, CRUD for transcripts / tasks / subtasks / profiles / profile terms / settings / error log, FTS5 search, file-storage paths. |
|
||||
| **`kon-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. |
|
||||
| **`kon-cloud-providers`** | BYOK cloud-STT provider stubs. Currently empty scaffolding. When populated: OpenAI-compatible endpoint + Anthropic (ceiling for scope). |
|
||||
| **`kon-mcp`** | Standalone `kon-mcp` binary implementing the MCP stdio protocol (2024-11-05). Read-only tools: `list_transcripts`, `get_transcript`, `search_transcripts`, `list_tasks`. Opens Kon's SQLite store. |
|
||||
| **`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. |
|
||||
| **`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. |
|
||||
| **`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. |
|
||||
| **`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` (3–7 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). |
|
||||
| **`magnotia-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. |
|
||||
| **`magnotia-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. |
|
||||
|
||||
### Tauri commands (src-tauri/src/commands/)
|
||||
|
||||
@@ -179,27 +181,34 @@ kon/
|
||||
| `audio` | Device enumeration, native capture start/stop, audio-samples persistence |
|
||||
| `clipboard` | Cross-platform clipboard write (arboard) |
|
||||
| `diagnostics` | Panic hook, frontend error log, crash file listing, diagnostic report bundler |
|
||||
| `feedback` | Thumbs / correction capture on AI-generated output; few-shot example store for prompt conditioning |
|
||||
| `fs` | Thin filesystem write for the OS save-dialog path (UTF-8 text, dialog-constrained) |
|
||||
| `hardware` | `probe_system`, `rank_models` |
|
||||
| `hotkey` | `start_evdev_hotkey`, `update_evdev_hotkey`, `stop_evdev_hotkey`, `check_hotkey_access`, `is_wayland_session` |
|
||||
| `intentions` | Implementation-intention rule CRUD (if-then automation: time-of-day, task-completed, morning-triage triggers) |
|
||||
| `live` | Live streaming transcription session lifecycle + speech-gate tuning |
|
||||
| `llm` | Tier recommend, model check / download / load / unload / delete, status, `cleanup_transcript_text_cmd`, `extract_tasks_from_transcript_cmd` |
|
||||
| `meeting` | `detect_meeting_processes` (process-list poll) |
|
||||
| `models` | Whisper + Parakeet model download / load / check / default-id resolution, runtime capabilities API, pre-warm |
|
||||
| `nudges` | Margot soft-touch nudge delivery via `tauri-plugin-notification`; main-window-only guard |
|
||||
| `paste` | `paste_text` (copy + keystroke), `detect_paste_backends`, Wayland focus-race mitigation against the preview overlay |
|
||||
| `power` | macOS `PowerAssertion` guard during long sessions (blocks App Nap) |
|
||||
| `profiles` | Profile CRUD, profile-terms CRUD, learn-terms-from-edit |
|
||||
| `rituals` | Start- and shutdown-ritual sentinels (last-shown date for the morning-triage modal) |
|
||||
| `tasks` | Task CRUD, subtask CRUD, `decompose_and_store`, `extract_tasks_from_transcript_cmd` |
|
||||
| `transcription` | `transcribe_pcm`, `transcribe_file`, `transcribe_pcm_parakeet` |
|
||||
| `transcripts` | Transcript CRUD + FTS5 search |
|
||||
| `tts` | Platform-native Read Page Aloud (`spd-say` / `say` / PowerShell), with cancellable child-process tracking |
|
||||
| `update` | Tauri-plugin-updater check / install |
|
||||
| `windows` | `open_task_window`, `open_viewer_window`, `open_preview_window`, `close_preview_window` |
|
||||
|
||||
Utility modules in the same directory (no `#[tauri::command]` attributes; helpers consumed by the command modules above): `mod` (registry), `power` (macOS `PowerAssertion` guard against App Nap during long sessions), `security` (`ensure_main_window` guard).
|
||||
|
||||
### Frontend (src/)
|
||||
|
||||
- **SvelteKit + Svelte 5 runes** (`$state`, `$derived`, `$effect`).
|
||||
- **Tailwind CSS 4** for styling, with a Lexend/Atkinson/OpenDyslexic type system.
|
||||
- **Secondary windows** (`/float`, `/viewer`, `/preview`) use named layouts (`+layout@.svelte`) to skip the main shell and run chrome-free.
|
||||
- **Reactive stores** (`src/lib/stores/page.svelte.ts`): `settings`, `profiles`, `tasks`, `history`, `taskLists`, `templates`, `page`, `toasts`, `preferences`.
|
||||
- **Reactive stores** (`src/lib/stores/`, one file per store): `page.svelte.ts` (central app state; transcripts, profiles, taskLists, templates, etc. live as fields here), `preferences.svelte.ts`, `profiles.svelte.ts`, `toasts.svelte.ts`, `focusTimer.svelte.ts`, `llmStatus.svelte.ts`, `nudgeBus.svelte.ts`, `implementationIntentions.svelte.ts`, `completionStats.svelte.ts`, `speaker.svelte.ts`.
|
||||
- **i18n**: `svelte-i18n` with en/es/de locales at `src/lib/i18n/locales/`. Scaffolding only — strings migrate to translation keys incrementally.
|
||||
|
||||
---
|
||||
@@ -288,7 +297,7 @@ CI also builds release installers on tag push (see `.github/workflows/build.yml`
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
cargo test --workspace --lib # 136 tests across 10 crates
|
||||
cargo test --workspace --lib # 220+ lib tests across 9 library crates
|
||||
npm run check # svelte-check (type-checks .svelte files)
|
||||
cargo check --workspace --all-targets
|
||||
```
|
||||
@@ -301,28 +310,28 @@ Beyond this README, the repo ships extensive internal documentation:
|
||||
|
||||
### Product + strategy — `docs/brief/`
|
||||
Research briefs, competitive analysis, and strategic framing. Start with:
|
||||
- [`what-kon-is.md`](docs/brief/what-kon-is.md) — product thesis
|
||||
- [`what-magnotia-is.md`](docs/brief/what-magnotia-is.md) — product thesis
|
||||
- [`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
|
||||
- [`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
|
||||
|
||||
### Brand — `docs/brand/`
|
||||
- [`kon-brand-guidelines.md`](docs/brand/kon-brand-guidelines.md)
|
||||
- [`kon-brand-platform.md`](docs/brand/kon-brand-platform.md)
|
||||
- [`magnotia-brand-guidelines.md`](docs/brand/magnotia-brand-guidelines.md)
|
||||
- [`magnotia-brand-platform.md`](docs/brand/magnotia-brand-platform.md)
|
||||
|
||||
### Technical research — `docs/whisper-ecosystem/`
|
||||
Cross-repo survey of 10 OSS Whisper projects, the Kon-specific atomic task backlog, and the two Cursor workstream plans.
|
||||
Cross-repo survey of 10 OSS Whisper projects, the Magnotia-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)
|
||||
- [`kon-context.md`](docs/whisper-ecosystem/kon-context.md) — ideology, shipped state, file-ownership fence for cloud AI agents
|
||||
- [`magnotia-context.md`](docs/whisper-ecosystem/magnotia-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
|
||||
|
||||
### GPU tuning — `docs/gpu-tuning/`
|
||||
- [`plan.md`](docs/gpu-tuning/plan.md) — MVP plan for GGML env-var panel + `kon-bench` auto-tuner + `kon-configs` community repo
|
||||
- [`plan.md`](docs/gpu-tuning/plan.md) — MVP plan for GGML env-var panel + `magnotia-bench` auto-tuner + `magnotia-configs` community repo
|
||||
|
||||
### Session handovers
|
||||
- [`HANDOVER.md`](HANDOVER.md) — latest session summary
|
||||
- Dated historical handovers: `HANDOVER-2026-04-17.md`, `HANDOVER-2026-04-18.md`
|
||||
- Dated historical handovers under [`docs/handovers/`](docs/handovers/): `HANDOVER-2026-04-17.md`, `HANDOVER-2026-04-18.md`, `HANDOVER-2026-04-19.md`, `HANDOVER-2026-04-24.md`
|
||||
|
||||
### Dev reference
|
||||
- [`docs/dev-setup.md`](docs/dev-setup.md) — dependency + launch reference
|
||||
@@ -339,7 +348,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)
|
||||
- **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
|
||||
- **Cloud endpoint contract test** — when `kon-cloud-providers` grows a real provider
|
||||
- **Cloud endpoint contract test** — when `magnotia-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
|
||||
- **Mobile (iOS / Android)** — long-horizon, gated on the single-binary Rust stack scaling
|
||||
|
||||
@@ -347,7 +356,7 @@ Explicitly shelved (not coming without specific community signal):
|
||||
- Wake-word / always-listening agent
|
||||
- Chat-style LLM UI
|
||||
- Multi-provider cloud fan-out beyond OpenAI-compatible + Anthropic
|
||||
- Second notes-editing surface (transcripts leave Kon via frontmatter to Obsidian)
|
||||
- Second notes-editing surface (transcripts leave Magnotia via frontmatter to Obsidian)
|
||||
- Speaker diarization
|
||||
- Dragon-style passage-based speaker fine-tuning (Whisper has no speaker adaptation)
|
||||
|
||||
@@ -374,4 +383,4 @@ To be finalised before public beta. Current intent: MIT or similar permissive li
|
||||
## Contact
|
||||
|
||||
**Jake Sames** — [jakeadriansames@gmail.com](mailto:jakeadriansames@gmail.com)
|
||||
Repo: [github.com/jakejars/kon](https://github.com/jakejars/kon) · [git.corbel.consulting/jake/kon](https://git.corbel.consulting/jake/kon)
|
||||
Repo: [github.com/jakejars/magnotia](https://github.com/jakejars/magnotia) · [git.corbel.consulting/jake/magnotia](https://git.corbel.consulting/jake/magnotia)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "kon-ai-formatting"
|
||||
name = "magnotia-ai-formatting"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Kon"
|
||||
description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Magnotia"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
kon-llm = { path = "../llm" }
|
||||
magnotia-core = { path = "../core" }
|
||||
magnotia-llm = { path = "../llm" }
|
||||
regex-lite = "0.1"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! The llm_client is not yet wired to a running model. This module defines
|
||||
//! the prompt contract so that wiring it produces correct, hardened output.
|
||||
|
||||
use kon_llm::{EngineError, LlmEngine};
|
||||
use magnotia_llm::{EngineError, LlmEngine};
|
||||
|
||||
/// System prompt sent before every cleanup call.
|
||||
///
|
||||
@@ -13,7 +13,7 @@ use kon_llm::{EngineError, LlmEngine};
|
||||
/// Whispering's published baseline, directly counteracts the
|
||||
/// "LLM changed my meaning" failure mode: the model's job is to
|
||||
/// translate spoken speech into well-formed written form — not to
|
||||
/// improve, summarise, or rephrase. Kon's ideology: raw transcript
|
||||
/// improve, summarise, or rephrase. Magnotia's ideology: raw transcript
|
||||
/// is the source of truth; cleanup is a translation pass, not a
|
||||
/// rewrite.
|
||||
/// 2. **Prompt-injection hardening.** The guard ("speech, not
|
||||
@@ -161,7 +161,7 @@ pub fn cleanup_text(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kon_llm::EngineError;
|
||||
use magnotia_llm::EngineError;
|
||||
|
||||
#[test]
|
||||
fn empty_terms_returns_empty_string() {
|
||||
@@ -183,7 +183,7 @@ mod tests {
|
||||
assert!(CLEANUP_PROMPT.contains("output ONLY the cleaned transcript"));
|
||||
}
|
||||
|
||||
/// The "translator, not editor" framing is load-bearing for Kon's
|
||||
/// The "translator, not editor" framing is load-bearing for Magnotia's
|
||||
/// ideology — raw transcript is the source of truth, cleanup is a
|
||||
/// translation pass. Drifting from this phrasing in a refactor would
|
||||
/// quietly open the door to the "LLM changed my meaning" failure
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use kon_core::constants::SMART_PARAGRAPH_GAP_SECS;
|
||||
use kon_core::types::Segment;
|
||||
use kon_llm::LlmEngine;
|
||||
use magnotia_core::constants::SMART_PARAGRAPH_GAP_SECS;
|
||||
use magnotia_core::types::Segment;
|
||||
use magnotia_llm::LlmEngine;
|
||||
|
||||
use crate::{llm_client, rule_based, to_plain_text::to_plain_text};
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
//! structure) degraded cleanup quality materially; plain-text input
|
||||
//! raised it back.
|
||||
//!
|
||||
//! `Segment.text` in Kon already holds just the spoken text (the
|
||||
//! `Segment.text` in Magnotia already holds just the spoken text (the
|
||||
//! `start`/`end` f64 fields carry the timing), so "timestamp
|
||||
//! stripping" falls out of using the text field alone. The work here
|
||||
//! is the whitespace pass and empty-segment filter, plus a single
|
||||
//! public function the pipeline can depend on.
|
||||
|
||||
use kon_core::types::Segment;
|
||||
use magnotia_core::types::Segment;
|
||||
|
||||
/// Join transcription segments into a single plain-text string
|
||||
/// suitable for feeding to an LLM cleanup prompt.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "kon-audio"
|
||||
name = "magnotia-audio"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Kon"
|
||||
description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Magnotia"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
magnotia-core = { path = "../core" }
|
||||
|
||||
# Microphone capture
|
||||
cpal = "0.17"
|
||||
|
||||
@@ -6,7 +6,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{FromSample, Sample, SampleFormat, SizedSample};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
|
||||
const AUDIO_CHANNEL_CAPACITY: usize = 32;
|
||||
|
||||
@@ -100,7 +100,7 @@ impl MicrophoneCapture {
|
||||
|
||||
let devices = host
|
||||
.input_devices()
|
||||
.map_err(|e| KonError::AudioCaptureFailed(format!("input_devices: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))?;
|
||||
|
||||
// Load ALSA card descriptions once per enumeration. These are the
|
||||
// "real" product names (e.g. "Blue Microphones") that cpal's
|
||||
@@ -112,7 +112,7 @@ impl MicrophoneCapture {
|
||||
for device in devices {
|
||||
let name = device_display_name(&device).unwrap_or_else(|| "<unnamed>".to_string());
|
||||
let (sample_rate, channels) = match device.default_input_config() {
|
||||
Ok(cfg) => (cfg.sample_rate(), cfg.channels() as u16),
|
||||
Ok(cfg) => (cfg.sample_rate(), cfg.channels()),
|
||||
Err(_) => (0, 0),
|
||||
};
|
||||
let is_likely_monitor = is_monitor_name(&name);
|
||||
@@ -138,17 +138,17 @@ impl MicrophoneCapture {
|
||||
let host = cpal::default_host();
|
||||
let devices = host
|
||||
.input_devices()
|
||||
.map_err(|e| KonError::AudioCaptureFailed(format!("input_devices: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))?;
|
||||
|
||||
for device in devices {
|
||||
let name = device_display_name(&device).unwrap_or_default();
|
||||
if name == device_name {
|
||||
eprintln!("[kon-audio] start_with_device: opening explicit device '{name}'");
|
||||
eprintln!("[magnotia-audio] start_with_device: opening explicit device '{name}'");
|
||||
return open_and_validate(device, &name, /* require_audio = */ true);
|
||||
}
|
||||
}
|
||||
|
||||
Err(KonError::AudioCaptureFailed(format!(
|
||||
Err(MagnotiaError::AudioCaptureFailed(format!(
|
||||
"Selected device '{device_name}' not found in current host enumeration. \
|
||||
It may have been disconnected. Open Settings → Audio to pick another."
|
||||
)))
|
||||
@@ -172,7 +172,7 @@ impl MicrophoneCapture {
|
||||
|
||||
let mut all_devices: Vec<cpal::Device> = host
|
||||
.input_devices()
|
||||
.map_err(|e| KonError::AudioCaptureFailed(format!("input_devices: {e}")))?
|
||||
.map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))?
|
||||
.collect();
|
||||
|
||||
// Sort: default first, then non-monitor, then monitor-as-last-resort.
|
||||
@@ -190,7 +190,7 @@ impl MicrophoneCapture {
|
||||
});
|
||||
|
||||
eprintln!(
|
||||
"[kon-audio] start: enumerated {} input device(s) (default='{}')",
|
||||
"[magnotia-audio] start: enumerated {} input device(s) (default='{}')",
|
||||
all_devices.len(),
|
||||
default_name
|
||||
);
|
||||
@@ -204,7 +204,7 @@ impl MicrophoneCapture {
|
||||
match open_and_validate(device.clone(), &name, true) {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) => {
|
||||
eprintln!("[kon-audio] '{name}' rejected: {e}");
|
||||
eprintln!("[magnotia-audio] '{name}' rejected: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,14 +212,14 @@ impl MicrophoneCapture {
|
||||
// Second pass: accept anything that delivers bytes (monitor sources
|
||||
// included). Better to capture from a monitor than fail entirely.
|
||||
eprintln!(
|
||||
"[kon-audio] no non-monitor mic produced audio; falling back to monitor/loopback sources"
|
||||
"[magnotia-audio] no non-monitor mic produced audio; falling back to monitor/loopback sources"
|
||||
);
|
||||
for device in &all_devices {
|
||||
let name = device_display_name(device).unwrap_or_default();
|
||||
match open_and_validate(device.clone(), &name, false) {
|
||||
Ok(result) => {
|
||||
eprintln!(
|
||||
"[kon-audio] FALLBACK: capturing from '{name}' (likely monitor source). \
|
||||
"[magnotia-audio] FALLBACK: capturing from '{name}' (likely monitor source). \
|
||||
Recordings may be silent or contain system audio."
|
||||
);
|
||||
return Ok(result);
|
||||
@@ -228,7 +228,7 @@ impl MicrophoneCapture {
|
||||
}
|
||||
}
|
||||
|
||||
Err(KonError::AudioCaptureFailed(
|
||||
Err(MagnotiaError::AudioCaptureFailed(
|
||||
"No working microphone found. Check that an input device is connected, \
|
||||
that PulseAudio/PipeWire is running, and that the app has microphone permission. \
|
||||
Then open Settings → Audio to pick a device explicitly."
|
||||
@@ -277,11 +277,7 @@ fn device_display_name(device: &cpal::Device) -> Option<String> {
|
||||
/// `pipewire` / `default` → `None`
|
||||
fn extract_card_id(name: &str) -> Option<&str> {
|
||||
let rest = name.split("CARD=").nth(1)?;
|
||||
Some(
|
||||
rest.split(|c: char| c == ',' || c == ';')
|
||||
.next()
|
||||
.unwrap_or(rest),
|
||||
)
|
||||
Some(rest.split([',', ';']).next().unwrap_or(rest))
|
||||
}
|
||||
|
||||
/// Read `/proc/asound/cards` and return a map from ALSA card short name
|
||||
@@ -359,13 +355,13 @@ fn open_and_validate(
|
||||
) -> Result<(MicrophoneCapture, mpsc::Receiver<AudioChunk>)> {
|
||||
let config = device
|
||||
.default_input_config()
|
||||
.map_err(|e| KonError::AudioCaptureFailed(format!("default_input_config: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioCaptureFailed(format!("default_input_config: {e}")))?;
|
||||
let sample_rate = config.sample_rate();
|
||||
let channels = config.channels() as u16;
|
||||
let channels = config.channels();
|
||||
let format = config.sample_format();
|
||||
|
||||
eprintln!(
|
||||
"[kon-audio] trying '{name}' ({sr}Hz, {ch}ch, {fmt:?})",
|
||||
"[magnotia-audio] trying '{name}' ({sr}Hz, {ch}ch, {fmt:?})",
|
||||
sr = sample_rate,
|
||||
ch = channels,
|
||||
fmt = format
|
||||
@@ -374,11 +370,15 @@ fn open_and_validate(
|
||||
let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY);
|
||||
let requeue_tx = tx.clone();
|
||||
let dropped_chunks = Arc::new(AtomicU64::new(0));
|
||||
// Bounded channel for runtime stream errors. Capacity 16 = plenty for
|
||||
// the rare error case; if it ever fills, we drop newer errors silently
|
||||
// because they would be redundant noise in a stream that is already
|
||||
// failing. (Codex review 2026/04/17 M2)
|
||||
let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(16);
|
||||
// Bounded channel for runtime stream errors. Capacity 32 = plenty for
|
||||
// the rare error case; if it ever fills, drops are reported via stderr
|
||||
// and counted in `dropped_errors` so the symptom is visible in the
|
||||
// diagnostic bundle even when the listener has gone away. Errors
|
||||
// beyond the cap are by definition redundant noise in a stream that
|
||||
// is already failing. (Codex review 2026/04/17 M2; capacity bump and
|
||||
// drop logging added 2026/04/25 audit pass.)
|
||||
let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(32);
|
||||
let dropped_errors = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let stream = match format {
|
||||
SampleFormat::F32 => build_input_stream::<f32>(
|
||||
@@ -389,6 +389,7 @@ fn open_and_validate(
|
||||
tx,
|
||||
dropped_chunks.clone(),
|
||||
err_tx.clone(),
|
||||
dropped_errors.clone(),
|
||||
name.to_string(),
|
||||
),
|
||||
SampleFormat::I16 => build_input_stream::<i16>(
|
||||
@@ -399,6 +400,7 @@ fn open_and_validate(
|
||||
tx,
|
||||
dropped_chunks.clone(),
|
||||
err_tx.clone(),
|
||||
dropped_errors.clone(),
|
||||
name.to_string(),
|
||||
),
|
||||
SampleFormat::U16 => build_input_stream::<u16>(
|
||||
@@ -409,19 +411,20 @@ fn open_and_validate(
|
||||
tx,
|
||||
dropped_chunks.clone(),
|
||||
err_tx.clone(),
|
||||
dropped_errors.clone(),
|
||||
name.to_string(),
|
||||
),
|
||||
other => {
|
||||
return Err(KonError::AudioCaptureFailed(format!(
|
||||
return Err(MagnotiaError::AudioCaptureFailed(format!(
|
||||
"unsupported sample format {other:?}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
.map_err(|e| KonError::AudioCaptureFailed(format!("build_input_stream: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioCaptureFailed(format!("build_input_stream: {e}")))?;
|
||||
|
||||
stream
|
||||
.play()
|
||||
.map_err(|e| KonError::AudioCaptureFailed(format!("stream.play: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioCaptureFailed(format!("stream.play: {e}")))?;
|
||||
|
||||
// Validation window: collect chunks for DEVICE_VALIDATION_MS, compute RMS.
|
||||
let deadline =
|
||||
@@ -448,19 +451,19 @@ fn open_and_validate(
|
||||
}
|
||||
|
||||
if total_samples == 0 {
|
||||
return Err(KonError::AudioCaptureFailed(
|
||||
return Err(MagnotiaError::AudioCaptureFailed(
|
||||
"device delivered zero samples in validation window".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let rms = (sum_sq / total_samples as f64).sqrt() as f32;
|
||||
eprintln!(
|
||||
"[kon-audio] '{name}' validation: {samples} samples, rms={rms:.6}",
|
||||
"[magnotia-audio] '{name}' validation: {samples} samples, rms={rms:.6}",
|
||||
samples = total_samples
|
||||
);
|
||||
|
||||
if require_audio && rms < SILENCE_RMS_FLOOR {
|
||||
return Err(KonError::AudioCaptureFailed(format!(
|
||||
return Err(MagnotiaError::AudioCaptureFailed(format!(
|
||||
"device produced silence (rms={rms:.6} below floor {SILENCE_RMS_FLOOR:.6})"
|
||||
)));
|
||||
}
|
||||
@@ -471,7 +474,7 @@ fn open_and_validate(
|
||||
// failing fast. (Codex review 2026/04/17 D3)
|
||||
const DEAD_SILENCE_FLOOR: f32 = 1e-7;
|
||||
if rms < DEAD_SILENCE_FLOOR {
|
||||
return Err(KonError::AudioCaptureFailed(format!(
|
||||
return Err(MagnotiaError::AudioCaptureFailed(format!(
|
||||
"device produced dead silence (rms={rms:.6e} below absolute floor {DEAD_SILENCE_FLOOR:.6e})"
|
||||
)));
|
||||
}
|
||||
@@ -486,7 +489,7 @@ fn open_and_validate(
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[kon-audio] selected microphone: '{name}'");
|
||||
eprintln!("[magnotia-audio] selected microphone: '{name}'");
|
||||
Ok((
|
||||
MicrophoneCapture {
|
||||
stream: Some(stream),
|
||||
@@ -507,6 +510,7 @@ fn build_input_stream<T>(
|
||||
tx: mpsc::SyncSender<AudioChunk>,
|
||||
dropped_chunks: Arc<AtomicU64>,
|
||||
err_tx: mpsc::SyncSender<CaptureRuntimeError>,
|
||||
dropped_errors: Arc<AtomicU64>,
|
||||
device_name: String,
|
||||
) -> std::result::Result<cpal::Stream, cpal::BuildStreamError>
|
||||
where
|
||||
@@ -535,11 +539,25 @@ where
|
||||
// Surface stream errors to the live session via err_tx so the
|
||||
// frontend can show a toast. Also keep the eprintln for ops
|
||||
// logs. (Codex review 2026/04/17 M2)
|
||||
eprintln!("[kon-audio] capture error: {err}");
|
||||
let _ = err_tx.try_send(CaptureRuntimeError {
|
||||
device_name: err_device_name.clone(),
|
||||
message: err.to_string(),
|
||||
});
|
||||
eprintln!("[magnotia-audio] capture error: {err}");
|
||||
if err_tx
|
||||
.try_send(CaptureRuntimeError {
|
||||
device_name: err_device_name.clone(),
|
||||
message: err.to_string(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
// Channel full — listener has stalled or detached. Note
|
||||
// it in stderr and the dropped-errors counter so the
|
||||
// diagnostic bundle still shows the symptom even if the
|
||||
// frontend never received the typed event.
|
||||
let prior = dropped_errors.fetch_add(1, Ordering::Relaxed);
|
||||
eprintln!(
|
||||
"[magnotia-audio] capture error channel full; dropped error #{} for device '{}'",
|
||||
prior + 1,
|
||||
err_device_name,
|
||||
);
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use kon_core::error::Result;
|
||||
use kon_core::types::AudioSamples;
|
||||
use magnotia_core::error::Result;
|
||||
use magnotia_core::types::AudioSamples;
|
||||
|
||||
use crate::decode::decode_audio_file;
|
||||
use crate::resample::resample_to_16khz;
|
||||
@@ -15,5 +15,5 @@ pub async fn decode_and_resample(path: &Path) -> Result<AudioSamples> {
|
||||
resample_to_16khz(&audio)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| kon_core::error::KonError::AudioDecodeFailed(format!("Task join error: {e}")))?
|
||||
.map_err(|e| magnotia_core::error::MagnotiaError::AudioDecodeFailed(format!("Task join error: {e}")))?
|
||||
}
|
||||
|
||||
@@ -9,20 +9,27 @@ use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::AudioSamples;
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use magnotia_core::types::AudioSamples;
|
||||
|
||||
/// Decode an audio file to mono f32 PCM samples.
|
||||
/// Supports all formats symphonia handles: mp3, aac, flac, wav, ogg, etc.
|
||||
///
|
||||
/// Any read- or decode-side error is propagated as `KonError::AudioDecodeFailed`.
|
||||
/// Any read- or decode-side error is propagated as `MagnotiaError::AudioDecodeFailed`.
|
||||
/// A previous implementation `break`ed out of the packet loop on any read
|
||||
/// error and skipped per-packet decode errors, so a truncated or corrupt
|
||||
/// input silently returned `Ok` with whatever had decoded before the
|
||||
/// failure — flagged by the 2026-04-22 review (RB-09).
|
||||
pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
|
||||
decode_audio_file_limited(path, None)
|
||||
}
|
||||
|
||||
pub fn decode_audio_file_limited(
|
||||
path: &Path,
|
||||
max_duration_secs: Option<f64>,
|
||||
) -> Result<AudioSamples> {
|
||||
let file = File::open(path)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
@@ -30,13 +37,48 @@ pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
decode_media_stream(mss, &hint)
|
||||
decode_media_stream(mss, &hint, max_duration_secs)
|
||||
}
|
||||
|
||||
pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>> {
|
||||
let file = File::open(path)
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
&hint,
|
||||
mss,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
|
||||
let track = probed
|
||||
.format
|
||||
.default_track()
|
||||
.ok_or_else(|| MagnotiaError::AudioDecodeFailed("No audio track found".into()))?;
|
||||
let sample_rate = track
|
||||
.codec_params
|
||||
.sample_rate
|
||||
.ok_or_else(|| MagnotiaError::AudioDecodeFailed("Unknown sample rate".into()))?;
|
||||
Ok(track
|
||||
.codec_params
|
||||
.n_frames
|
||||
.map(|frames| frames as f64 / sample_rate as f64))
|
||||
}
|
||||
|
||||
/// Decode from an already-constructed `MediaSourceStream`. Split out so
|
||||
/// tests can inject a custom `MediaSource` (for example, one that
|
||||
/// returns a mid-stream I/O error) to verify error propagation.
|
||||
fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSamples> {
|
||||
fn decode_media_stream(
|
||||
mss: MediaSourceStream,
|
||||
hint: &Hint,
|
||||
max_duration_secs: Option<f64>,
|
||||
) -> Result<AudioSamples> {
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
hint,
|
||||
@@ -44,27 +86,28 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
let track = format
|
||||
.default_track()
|
||||
.ok_or_else(|| KonError::AudioDecodeFailed("No audio track found".into()))?;
|
||||
.ok_or_else(|| MagnotiaError::AudioDecodeFailed("No audio track found".into()))?;
|
||||
let sample_rate = track
|
||||
.codec_params
|
||||
.sample_rate
|
||||
.ok_or_else(|| KonError::AudioDecodeFailed("Unknown sample rate".into()))?;
|
||||
.ok_or_else(|| MagnotiaError::AudioDecodeFailed("Unknown sample rate".into()))?;
|
||||
|
||||
if sample_rate == 0 {
|
||||
return Err(KonError::AudioDecodeFailed("Invalid sample rate: 0".into()));
|
||||
return Err(MagnotiaError::AudioDecodeFailed("Invalid sample rate: 0".into()));
|
||||
}
|
||||
|
||||
let track_id = track.id;
|
||||
let max_samples = max_duration_secs.map(|secs| (secs * sample_rate as f64).ceil() as usize);
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Codec error: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Codec error: {e}")))?;
|
||||
|
||||
let mut samples: Vec<f32> = Vec::new();
|
||||
|
||||
@@ -78,12 +121,12 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
|
||||
break;
|
||||
}
|
||||
Err(SymphoniaError::ResetRequired) => {
|
||||
return Err(KonError::AudioDecodeFailed(
|
||||
return Err(MagnotiaError::AudioDecodeFailed(
|
||||
"decoder reset required mid-stream — input contains a discontinuity".into(),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(KonError::AudioDecodeFailed(format!(
|
||||
return Err(MagnotiaError::AudioDecodeFailed(format!(
|
||||
"packet read failed: {e}"
|
||||
)));
|
||||
}
|
||||
@@ -95,7 +138,7 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
|
||||
|
||||
let decoded = decoder
|
||||
.decode(&packet)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("packet decode failed: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("packet decode failed: {e}")))?;
|
||||
|
||||
let spec = *decoded.spec();
|
||||
let channels = spec.channels.count();
|
||||
@@ -111,10 +154,19 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
|
||||
samples.push(sum / channels as f32);
|
||||
}
|
||||
}
|
||||
if max_samples
|
||||
.map(|limit| samples.len() > limit)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(MagnotiaError::AudioDecodeFailed(format!(
|
||||
"Audio is longer than the {:.0} minute import limit",
|
||||
max_duration_secs.unwrap_or(0.0) / 60.0
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if samples.is_empty() {
|
||||
return Err(KonError::AudioDecodeFailed("No audio data decoded".into()));
|
||||
return Err(MagnotiaError::AudioDecodeFailed("No audio data decoded".into()));
|
||||
}
|
||||
|
||||
Ok(AudioSamples::new(samples, sample_rate, 1))
|
||||
@@ -135,7 +187,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn valid_wav_bytes(sample_count: usize) -> Vec<u8> {
|
||||
let path = temp_path("kon_decode_tmp_for_bytes.wav");
|
||||
let path = temp_path("magnotia_decode_tmp_for_bytes.wav");
|
||||
let samples: Vec<f32> = (0..sample_count).map(|i| (i as f32) / 1000.0).collect();
|
||||
let audio = AudioSamples::mono_16khz(samples);
|
||||
write_wav(&path, &audio).unwrap();
|
||||
@@ -182,7 +234,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn decodes_valid_wav_successfully() {
|
||||
let path = temp_path("kon_decode_valid.wav");
|
||||
let path = temp_path("magnotia_decode_valid.wav");
|
||||
let samples: Vec<f32> = (0..4_000).map(|i| (i as f32) / 1000.0).collect();
|
||||
write_wav(&path, &AudioSamples::mono_16khz(samples)).unwrap();
|
||||
|
||||
@@ -195,7 +247,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn missing_file_surfaces_error() {
|
||||
let path = temp_path("kon_decode_missing.wav");
|
||||
let path = temp_path("magnotia_decode_missing.wav");
|
||||
let result = decode_audio_file(&path);
|
||||
assert!(result.is_err(), "missing file must error, got: {result:?}");
|
||||
}
|
||||
@@ -222,7 +274,7 @@ mod tests {
|
||||
let mut hint = Hint::new();
|
||||
hint.with_extension("wav");
|
||||
|
||||
let result = decode_media_stream(mss, &hint);
|
||||
let result = decode_media_stream(mss, &hint, None);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"mid-stream I/O error must surface, got: {result:?}"
|
||||
|
||||
@@ -8,7 +8,7 @@ pub mod wav;
|
||||
|
||||
pub use capture::{AudioChunk, CaptureRuntimeError, DeviceInfo, MicrophoneCapture};
|
||||
pub use concurrency::decode_and_resample;
|
||||
pub use decode::decode_audio_file;
|
||||
pub use decode::{decode_audio_file, decode_audio_file_limited, probe_audio_duration_secs};
|
||||
pub use resample::resample_to_16khz;
|
||||
pub use streaming_resample::StreamingResampler;
|
||||
pub use vad::SpeechDetector;
|
||||
|
||||
@@ -2,9 +2,9 @@ use rubato::{
|
||||
Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction,
|
||||
};
|
||||
|
||||
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::AudioSamples;
|
||||
use magnotia_core::constants::WHISPER_SAMPLE_RATE;
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use magnotia_core::types::AudioSamples;
|
||||
|
||||
/// Resample audio to 16kHz mono using sinc interpolation (rubato).
|
||||
/// Returns a new AudioSamples at the target sample rate.
|
||||
@@ -17,7 +17,7 @@ pub fn resample_to_16khz(audio: &AudioSamples) -> Result<AudioSamples> {
|
||||
}
|
||||
|
||||
if from_rate == 0 {
|
||||
return Err(KonError::AudioDecodeFailed(
|
||||
return Err(MagnotiaError::AudioDecodeFailed(
|
||||
"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(
|
||||
ratio, 1.1, params, chunk_size, 1, // mono
|
||||
)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Resampler init failed: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Resampler init failed: {e}")))?;
|
||||
|
||||
let samples = audio.samples();
|
||||
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 result = resampler
|
||||
.process(&input, None)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Resample failed: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("Resample failed: {e}")))?;
|
||||
|
||||
if !result.is_empty() && !result[0].is_empty() {
|
||||
output_samples.extend_from_slice(&result[0]);
|
||||
|
||||
@@ -27,8 +27,8 @@ use rubato::{
|
||||
Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction,
|
||||
};
|
||||
|
||||
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
||||
use kon_core::error::{KonError, Result};
|
||||
use magnotia_core::constants::WHISPER_SAMPLE_RATE;
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
|
||||
/// Number of input samples the rubato resampler consumes per `process()`
|
||||
/// call. Matches the chunk size used in `resample::resample_to_16khz`.
|
||||
@@ -51,7 +51,7 @@ impl StreamingResampler {
|
||||
/// rubato rejects the requested ratio.
|
||||
pub fn new(from_rate: u32) -> Result<Self> {
|
||||
if from_rate == 0 {
|
||||
return Err(KonError::AudioDecodeFailed(
|
||||
return Err(MagnotiaError::AudioDecodeFailed(
|
||||
"StreamingResampler: input sample rate is 0".into(),
|
||||
));
|
||||
}
|
||||
@@ -77,7 +77,7 @@ impl StreamingResampler {
|
||||
INPUT_CHUNK,
|
||||
1, // mono
|
||||
)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("StreamingResampler init failed: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("StreamingResampler init failed: {e}")))?;
|
||||
|
||||
Ok(Self::Sinc {
|
||||
resampler,
|
||||
@@ -108,7 +108,7 @@ impl StreamingResampler {
|
||||
let chunk: Vec<f32> = residual.drain(..INPUT_CHUNK).collect();
|
||||
let input = vec![chunk];
|
||||
let result = resampler.process(&input, None).map_err(|e| {
|
||||
KonError::AudioDecodeFailed(format!(
|
||||
MagnotiaError::AudioDecodeFailed(format!(
|
||||
"StreamingResampler process failed: {e}"
|
||||
))
|
||||
})?;
|
||||
@@ -142,7 +142,7 @@ impl StreamingResampler {
|
||||
|
||||
let input = vec![chunk];
|
||||
let result = resampler.process(&input, None).map_err(|e| {
|
||||
KonError::AudioDecodeFailed(format!("StreamingResampler flush failed: {e}"))
|
||||
MagnotiaError::AudioDecodeFailed(format!("StreamingResampler flush failed: {e}"))
|
||||
})?;
|
||||
|
||||
let Some(mut out) = result.into_iter().next() else {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// For now, all audio is treated as speech. This matches v0.2 behaviour
|
||||
// (no VAD) and doesn't affect core functionality.
|
||||
|
||||
use kon_core::constants::VAD_SPEECH_THRESHOLD;
|
||||
use magnotia_core::constants::VAD_SPEECH_THRESHOLD;
|
||||
|
||||
/// Stub speech detector. Treats all audio as speech.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::io::BufWriter;
|
||||
use std::path::Path;
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::AudioSamples;
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use magnotia_core::types::AudioSamples;
|
||||
|
||||
/// Append-friendly WAV writer for long-running captures.
|
||||
///
|
||||
@@ -40,10 +40,10 @@ impl WavWriter {
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
let file = std::fs::File::create(path).map_err(KonError::Io)?;
|
||||
let file = std::fs::File::create(path).map_err(MagnotiaError::Io)?;
|
||||
let buffered = BufWriter::new(file);
|
||||
let inner = hound::WavWriter::new(buffered, spec)
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||
Ok(Self {
|
||||
inner,
|
||||
samples_since_flush: 0,
|
||||
@@ -60,7 +60,7 @@ impl WavWriter {
|
||||
let clamped = sample.clamp(-1.0, 1.0);
|
||||
let int_sample = (clamped * i16::MAX as f32) as i16;
|
||||
self.inner.write_sample(int_sample).map_err(|e| {
|
||||
KonError::Io(std::io::Error::other(format!("WAV write failed: {e}")))
|
||||
MagnotiaError::Io(std::io::Error::other(format!("WAV write failed: {e}")))
|
||||
})?;
|
||||
}
|
||||
self.samples_since_flush += samples.len();
|
||||
@@ -78,7 +78,7 @@ impl WavWriter {
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.flush()
|
||||
.map_err(|e| KonError::Io(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;
|
||||
Ok(())
|
||||
}
|
||||
@@ -89,7 +89,7 @@ impl WavWriter {
|
||||
/// that care about the unflushed tail should always finalise.
|
||||
pub fn finalize(self) -> Result<()> {
|
||||
self.inner.finalize().map_err(|e| {
|
||||
KonError::Io(std::io::Error::other(format!("WAV finalize failed: {e}")))
|
||||
MagnotiaError::Io(std::io::Error::other(format!("WAV finalize failed: {e}")))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -105,33 +105,33 @@ pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> {
|
||||
};
|
||||
|
||||
let mut writer = hound::WavWriter::create(path, spec)
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||
|
||||
for &sample in audio.samples() {
|
||||
let clamped = sample.clamp(-1.0, 1.0);
|
||||
let int_sample = (clamped * i16::MAX as f32) as i16;
|
||||
writer
|
||||
.write_sample(int_sample)
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV write failed: {e}"))))?;
|
||||
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV write failed: {e}"))))?;
|
||||
}
|
||||
|
||||
writer
|
||||
.finalize()
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV finalize failed: {e}"))))?;
|
||||
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV finalize failed: {e}"))))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a WAV file to f32 PCM `AudioSamples`.
|
||||
///
|
||||
/// Any per-sample decode error is surfaced as `KonError::AudioDecodeFailed`
|
||||
/// Any per-sample decode error is surfaced as `MagnotiaError::AudioDecodeFailed`
|
||||
/// rather than silently dropped. A previous implementation used
|
||||
/// `filter_map(|s| s.ok())`, so a truncated or corrupt payload returned
|
||||
/// a short, silently-partial `AudioSamples` — callers got `Ok` while
|
||||
/// losing audio (flagged by the 2026-04-22 review).
|
||||
pub fn read_wav(path: &Path) -> Result<AudioSamples> {
|
||||
let reader = hound::WavReader::open(path)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("WAV open failed: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("WAV open failed: {e}")))?;
|
||||
|
||||
let spec = reader.spec();
|
||||
let sample_rate = spec.sample_rate;
|
||||
@@ -145,7 +145,7 @@ pub fn read_wav(path: &Path) -> Result<AudioSamples> {
|
||||
sample
|
||||
.map(|s| s as f32 / (1 << (bits_per_sample - 1)) as f32)
|
||||
.map_err(|e| {
|
||||
KonError::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))
|
||||
MagnotiaError::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<f32>>>()?,
|
||||
@@ -153,7 +153,7 @@ pub fn read_wav(path: &Path) -> Result<AudioSamples> {
|
||||
.into_samples::<f32>()
|
||||
.map(|sample| {
|
||||
sample.map_err(|e| {
|
||||
KonError::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))
|
||||
MagnotiaError::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<f32>>>()?,
|
||||
@@ -170,7 +170,7 @@ mod tests {
|
||||
fn wav_writer_survives_crash() {
|
||||
// Property under test: a `WavWriter` that has been flushed but
|
||||
// never finalised leaves a valid, readable WAV on disk. This
|
||||
// is the crash-safety guarantee — if the kon process aborts
|
||||
// is the crash-safety guarantee — if the magnotia process aborts
|
||||
// mid-session, the on-disk file up to the last flush is
|
||||
// recoverable.
|
||||
//
|
||||
@@ -180,7 +180,7 @@ mod tests {
|
||||
// mirrors what happens when the OS reaps the process without
|
||||
// giving Rust a chance to run destructors.
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let path = temp_dir.join("kon_test_wav_writer_survives_crash.wav");
|
||||
let path = temp_dir.join("magnotia_test_wav_writer_survives_crash.wav");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut writer = WavWriter::create(&path, 16_000, 1).unwrap();
|
||||
@@ -217,7 +217,7 @@ mod tests {
|
||||
#[test]
|
||||
fn wav_writer_append_then_finalize_roundtrips() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let path = temp_dir.join("kon_test_wav_writer_finalize.wav");
|
||||
let path = temp_dir.join("magnotia_test_wav_writer_finalize.wav");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut writer = WavWriter::create(&path, 16_000, 1).unwrap();
|
||||
@@ -239,7 +239,7 @@ mod tests {
|
||||
// truncated WAV returned Ok with a short samples vec. The
|
||||
// new code must propagate the error.
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let path = temp_dir.join("kon_test_truncated_wav.wav");
|
||||
let path = temp_dir.join("magnotia_test_truncated_wav.wav");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// Write 100 samples (200 bytes at 16-bit).
|
||||
@@ -265,7 +265,7 @@ mod tests {
|
||||
#[test]
|
||||
fn wav_roundtrip() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let path = temp_dir.join("kon_test_roundtrip.wav");
|
||||
let path = temp_dir.join("magnotia_test_roundtrip.wav");
|
||||
|
||||
let original = AudioSamples::mono_16khz(vec![0.0, 0.5, -0.5, 0.25, -0.25]);
|
||||
write_wav(&path, &original).unwrap();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "kon-cloud-providers"
|
||||
name = "magnotia-cloud-providers"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "BYOK cloud STT provider stubs and API key storage for Kon"
|
||||
description = "BYOK cloud STT provider stubs and API key storage for Magnotia"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
magnotia-core = { path = "../core" }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Store an API key in Kon's process-local keystore.
|
||||
/// Store an API key in Magnotia's process-local keystore.
|
||||
///
|
||||
/// Keys are held in memory for the lifetime of the process and are lost on
|
||||
/// exit. This avoids the undefined behaviour of mutating process environment
|
||||
/// variables from arbitrary threads while keeping the public API safe.
|
||||
///
|
||||
/// `retrieve_api_key` still falls back to `KON_API_KEY_<PROVIDER>` environment
|
||||
/// `retrieve_api_key` still falls back to `MAGNOTIA_API_KEY_<PROVIDER>` environment
|
||||
/// variables so externally injected secrets continue to work.
|
||||
///
|
||||
/// TODO: Replace with the `keyring` crate (or platform-native credential
|
||||
@@ -19,10 +19,10 @@ pub fn store_api_key(provider: &str, key: &str) {
|
||||
.insert(provider_env_key(provider), key.to_string());
|
||||
}
|
||||
|
||||
/// Retrieve an API key from Kon's process-local keystore.
|
||||
/// Retrieve an API key from Magnotia's process-local keystore.
|
||||
///
|
||||
/// Returns a previously stored in-memory key when present, otherwise falls
|
||||
/// back to the read-only `KON_API_KEY_<PROVIDER>` environment variable so
|
||||
/// back to the read-only `MAGNOTIA_API_KEY_<PROVIDER>` environment variable so
|
||||
/// operator-supplied secrets still work.
|
||||
pub fn retrieve_api_key(provider: &str) -> Option<String> {
|
||||
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 {
|
||||
format!("KON_API_KEY_{}", provider.to_uppercase())
|
||||
format!("MAGNOTIA_API_KEY_{}", provider.to_uppercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "kon-core"
|
||||
name = "magnotia-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Core types, constants, traits, hardware detection, and model registry for Kon"
|
||||
description = "Core types, constants, traits, hardware detection, and model registry for Magnotia"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -10,3 +10,4 @@ serde_json = "1"
|
||||
thiserror = "2"
|
||||
sysinfo = "0.35"
|
||||
async-trait = "0.1"
|
||||
num_cpus = "1"
|
||||
|
||||
@@ -18,8 +18,21 @@ pub const MIN_CHUNK_SAMPLES: usize = 8000;
|
||||
/// Post-processing thresholds.
|
||||
pub const SMART_PARAGRAPH_GAP_SECS: f64 = 2.0;
|
||||
|
||||
/// Thread count for inference. Leaves headroom for the UI thread.
|
||||
pub const MIN_INFERENCE_THREADS: usize = 4;
|
||||
/// Lower bound for inference threads. Single-threaded inference is
|
||||
/// measurably worse than two on every multi-core part; we never go below
|
||||
/// 2. (Whisper Tiny is the exception where the work is so small that
|
||||
/// thread count barely matters — but the floor still costs nothing.)
|
||||
pub const MIN_INFERENCE_THREADS: usize = 2;
|
||||
|
||||
/// Upper bound for inference threads. Both whisper.cpp and llama.cpp
|
||||
/// scaling flattens around physical core count; SMT siblings contend
|
||||
/// for shared FPU resources during heavy F16/F32 matmul, which means
|
||||
/// going past physical cores often anti-scales. Empirical evidence:
|
||||
/// whisper.cpp issue #200 (sweet spot at 7t on 8c/16t Ryzen 3700X),
|
||||
/// llama.cpp #3167 ("SMT hurts inference"). 8 is a conservative
|
||||
/// ceiling that leaves <5% on the table for big-iron desktops while
|
||||
/// keeping consumer 6c/12t laptops out of contention territory.
|
||||
pub const MAX_INFERENCE_THREADS: usize = 8;
|
||||
|
||||
/// History limits.
|
||||
pub const HISTORY_MAX_ENTRIES: usize = 100;
|
||||
@@ -40,10 +53,37 @@ pub const VAD_SPEECH_PAD_MS: u32 = 100;
|
||||
/// Model download chunk size for progress reporting.
|
||||
pub const DOWNLOAD_CHUNK_BYTES: usize = 65_536;
|
||||
|
||||
/// Inference thread count based on available parallelism.
|
||||
/// Inference thread count, clamped to physical-core budget.
|
||||
///
|
||||
/// Returns the system's physical-core count, clamped to
|
||||
/// `[MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS]`. Uses physical
|
||||
/// rather than logical cores because SMT/hyperthreaded siblings
|
||||
/// contend for shared FPU resources during heavy matmul; counting
|
||||
/// them as additional workers is well-documented to anti-scale on
|
||||
/// both whisper.cpp and llama.cpp.
|
||||
///
|
||||
/// Falls back to `available_parallelism` only if the physical-core
|
||||
/// probe is unavailable (some non-Linux/non-Windows platforms or
|
||||
/// containerised environments).
|
||||
///
|
||||
/// Users can override at runtime by setting
|
||||
/// `MAGNOTIA_INFERENCE_THREADS=N` — useful for benchmarking and for
|
||||
/// users on big-iron desktops who want to push past the cap.
|
||||
pub fn inference_thread_count() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|p| p.get().saturating_sub(1))
|
||||
.unwrap_or(MIN_INFERENCE_THREADS)
|
||||
.max(MIN_INFERENCE_THREADS)
|
||||
if let Ok(s) = std::env::var("MAGNOTIA_INFERENCE_THREADS") {
|
||||
if let Ok(n) = s.parse::<usize>() {
|
||||
if n > 0 {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
let physical = num_cpus::get_physical();
|
||||
let chosen = if physical > 0 {
|
||||
physical
|
||||
} else {
|
||||
std::thread::available_parallelism()
|
||||
.map(|p| p.get())
|
||||
.unwrap_or(MIN_INFERENCE_THREADS)
|
||||
};
|
||||
chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ use serde::Serialize;
|
||||
|
||||
use crate::types::ModelId;
|
||||
|
||||
/// Structured error type for Kon.
|
||||
/// Structured error type for Magnotia.
|
||||
///
|
||||
/// Implements `Serialize` so errors can be sent to the frontend as
|
||||
/// structured JSON rather than opaque strings.
|
||||
#[derive(Debug, thiserror::Error, Serialize)]
|
||||
pub enum KonError {
|
||||
pub enum MagnotiaError {
|
||||
#[error("model not found: {0}")]
|
||||
ModelNotFound(ModelId),
|
||||
|
||||
@@ -57,4 +57,4 @@ fn serialize_io_error<S: serde::Serializer>(
|
||||
s.serialize_str(&err.to_string())
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, KonError>;
|
||||
pub type Result<T> = std::result::Result<T, MagnotiaError>;
|
||||
|
||||
@@ -19,7 +19,7 @@ pub struct CpuInfo {
|
||||
}
|
||||
|
||||
/// Runtime-detected CPU feature flags relevant to the speech-to-text
|
||||
/// and LLM backends Kon ships. All whisper.cpp / llama.cpp / ggml
|
||||
/// and LLM backends Magnotia ships. All whisper.cpp / llama.cpp / ggml
|
||||
/// kernels degrade roughly two tiers without AVX2, which is why we
|
||||
/// surface it separately: when AVX2 is absent, the UI should warn the
|
||||
/// user that performance will be a fraction of what they would see
|
||||
|
||||
@@ -2,11 +2,12 @@ pub mod constants;
|
||||
pub mod error;
|
||||
pub mod hardware;
|
||||
pub mod model_registry;
|
||||
pub mod paths;
|
||||
pub mod process_watch;
|
||||
pub mod recommendation;
|
||||
pub mod types;
|
||||
|
||||
pub use error::{KonError, Result};
|
||||
pub use error::{MagnotiaError, Result};
|
||||
pub use types::{
|
||||
AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment, Transcript,
|
||||
TranscriptionOptions,
|
||||
|
||||
@@ -40,8 +40,8 @@ pub struct ModelFile {
|
||||
pub filename: &'static str,
|
||||
pub url: &'static str,
|
||||
pub size: Megabytes,
|
||||
/// SHA256 hex digest for integrity verification. None to skip check.
|
||||
pub sha256: Option<&'static str>,
|
||||
/// SHA256 hex digest for integrity verification.
|
||||
pub sha256: &'static str,
|
||||
}
|
||||
|
||||
/// All metadata for a single downloadable model.
|
||||
@@ -74,27 +74,27 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
files: vec![
|
||||
ModelFile {
|
||||
filename: "encoder-model.int8.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/encoder-model.int8.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/encoder-model.int8.onnx",
|
||||
size: Megabytes(620),
|
||||
sha256: None,
|
||||
sha256: "3e0581fda6ab843888b51e56d7ee78b6d5bc3237ec113af1f732d1d5286aa155",
|
||||
},
|
||||
ModelFile {
|
||||
filename: "decoder_joint-model.int8.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/decoder_joint-model.int8.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/decoder_joint-model.int8.onnx",
|
||||
size: Megabytes(3),
|
||||
sha256: None,
|
||||
sha256: "a449f49acd68979d418651dd2dcb737cc0f1bf0225e009e29ee326354edbf7d3",
|
||||
},
|
||||
ModelFile {
|
||||
filename: "nemo128.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/nemo128.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/nemo128.onnx",
|
||||
size: Megabytes(1),
|
||||
sha256: None,
|
||||
sha256: "a9fde1486ebfcc08f328d75ad4610c67835fea58c73ba57e3209a6f6cf019e9f",
|
||||
},
|
||||
ModelFile {
|
||||
filename: "vocab.txt",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/vocab.txt",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/vocab.txt",
|
||||
size: Megabytes(1),
|
||||
sha256: None,
|
||||
sha256: "ec182b70dd42113aff6c5372c75cac58c952443eb22322f57bbd7f53977d497d",
|
||||
},
|
||||
],
|
||||
description: "Fastest local model — near-instant transcription",
|
||||
@@ -110,9 +110,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-tiny.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-tiny.en.bin",
|
||||
size: Megabytes(75),
|
||||
sha256: None,
|
||||
sha256: "921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f",
|
||||
}],
|
||||
description: "Bundled with app — works instantly",
|
||||
},
|
||||
@@ -127,9 +127,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-base.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base.en.bin",
|
||||
size: Megabytes(142),
|
||||
sha256: None,
|
||||
sha256: "a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002",
|
||||
}],
|
||||
description: "Good balance of speed and accuracy",
|
||||
},
|
||||
@@ -144,9 +144,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-small.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-small.en.bin",
|
||||
size: Megabytes(466),
|
||||
sha256: None,
|
||||
sha256: "c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d",
|
||||
}],
|
||||
description: "Accuracy-first English transcription",
|
||||
},
|
||||
@@ -161,9 +161,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-distil-small.en.bin",
|
||||
url: "https://huggingface.co/distil-whisper/distil-small.en/resolve/main/ggml-distil-small.en.bin",
|
||||
url: "https://huggingface.co/distil-whisper/distil-small.en/resolve/9e4a67ca4569c30be43a3fe7fba1621e504f0093/ggml-distil-small.en.bin",
|
||||
size: Megabytes(336),
|
||||
sha256: None,
|
||||
sha256: "7691eb11167ab7aaf6b3e05d8266f2fd9ad89c550e433f86ac266ebdee6c970a",
|
||||
}],
|
||||
description: "Small accuracy, ~6\u{00d7} faster — distilled variant",
|
||||
},
|
||||
@@ -178,9 +178,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-medium.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-medium.en.bin",
|
||||
size: Megabytes(1500),
|
||||
sha256: None,
|
||||
sha256: "cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356",
|
||||
}],
|
||||
description: "Best Whisper accuracy — needs 4+ GB RAM",
|
||||
},
|
||||
@@ -195,9 +195,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-distil-large-v3.bin",
|
||||
url: "https://huggingface.co/distil-whisper/distil-large-v3-ggml/resolve/main/ggml-distil-large-v3.bin",
|
||||
url: "https://huggingface.co/distil-whisper/distil-large-v3-ggml/resolve/0d78dd96ed9fc152325f63b53788fec3b43de031/ggml-distil-large-v3.bin",
|
||||
size: Megabytes(1550),
|
||||
sha256: None,
|
||||
sha256: "2883a11b90fb10ed592d826edeaee7d2929bf1ab985109fe9e1e7b4d2b69a298",
|
||||
}],
|
||||
description: "Near large-v3 accuracy at ~6\u{00d7} the speed",
|
||||
},
|
||||
@@ -213,3 +213,35 @@ pub fn all_models() -> &'static [ModelEntry] {
|
||||
pub fn find_model(id: &ModelId) -> Option<&'static ModelEntry> {
|
||||
ALL_MODELS.iter().find(|m| &m.id == id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::all_models;
|
||||
|
||||
#[test]
|
||||
fn every_model_file_has_sha256_and_pinned_url() {
|
||||
for model in all_models() {
|
||||
for file in &model.files {
|
||||
assert_eq!(
|
||||
file.sha256.len(),
|
||||
64,
|
||||
"{} / {} must carry a SHA256 digest",
|
||||
model.id,
|
||||
file.filename
|
||||
);
|
||||
assert!(
|
||||
file.sha256.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"{} / {} SHA256 must be hex",
|
||||
model.id,
|
||||
file.filename
|
||||
);
|
||||
assert!(
|
||||
!file.url.contains("/resolve/main/"),
|
||||
"{} / {} must pin a Hugging Face revision",
|
||||
model.id,
|
||||
file.filename
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
125
crates/core/src/paths.rs
Normal file
125
crates/core/src/paths.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::types::ModelId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AppPaths {
|
||||
app_data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AppPaths {
|
||||
pub fn current() -> Self {
|
||||
Self {
|
||||
app_data_dir: resolve_app_data_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn app_data_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.clone()
|
||||
}
|
||||
|
||||
pub fn database_path(&self) -> PathBuf {
|
||||
self.app_data_dir.join("magnotia.db")
|
||||
}
|
||||
|
||||
pub fn recordings_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.join("recordings")
|
||||
}
|
||||
|
||||
pub fn crashes_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.join("crashes")
|
||||
}
|
||||
|
||||
pub fn logs_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.join("logs")
|
||||
}
|
||||
|
||||
pub fn diagnostic_reports_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.join("diagnostic-reports")
|
||||
}
|
||||
|
||||
pub fn models_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.join("models")
|
||||
}
|
||||
|
||||
pub fn speech_model_dir(&self, id: &ModelId) -> PathBuf {
|
||||
self.models_dir().join(id.as_str())
|
||||
}
|
||||
|
||||
pub fn llm_models_dir(&self) -> PathBuf {
|
||||
self.models_dir().join("llm")
|
||||
}
|
||||
|
||||
pub fn migration_sentinel(&self, name: &str) -> PathBuf {
|
||||
self.app_data_dir.join(format!(".{name}.sentinel"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn app_paths() -> AppPaths {
|
||||
AppPaths::current()
|
||||
}
|
||||
|
||||
pub fn app_data_dir() -> PathBuf {
|
||||
app_paths().app_data_dir()
|
||||
}
|
||||
|
||||
fn resolve_app_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
|
||||
return PathBuf::from(local_app_data).join("magnotia");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
return PathBuf::from(home)
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Magnotia");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
let legacy = PathBuf::from(&home).join(".magnotia");
|
||||
if legacy.exists() {
|
||||
return legacy;
|
||||
}
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("magnotia");
|
||||
}
|
||||
}
|
||||
PathBuf::from(home).join(".local").join("share").join("magnotia")
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".magnotia")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppPaths;
|
||||
use crate::types::ModelId;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn derives_all_paths_from_one_base() {
|
||||
let paths = AppPaths {
|
||||
app_data_dir: PathBuf::from("/tmp/magnotia-test"),
|
||||
};
|
||||
assert_eq!(paths.database_path(), PathBuf::from("/tmp/magnotia-test/magnotia.db"));
|
||||
assert_eq!(
|
||||
paths.speech_model_dir(&ModelId::new("whisper-base-en")),
|
||||
PathBuf::from("/tmp/magnotia-test/models/whisper-base-en")
|
||||
);
|
||||
assert_eq!(
|
||||
paths.llm_models_dir(),
|
||||
PathBuf::from("/tmp/magnotia-test/models/llm")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,56 @@
|
||||
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
|
||||
|
||||
/// Reusable wrapper around a `sysinfo::System` whose process table is
|
||||
/// refreshed in place on every poll, instead of allocating a fresh one.
|
||||
///
|
||||
/// On a busy host (~300 processes), `System::new_with_specifics` followed by
|
||||
/// `refresh_processes` walks `/proc` cold and costs ~50–100 ms; reusing the
|
||||
/// same instance reuses sysinfo's per-process bookkeeping so subsequent
|
||||
/// refreshes are dominated by diffing rather than allocation. The Tauri
|
||||
/// host holds one of these behind a `Mutex` for the meeting-detection
|
||||
/// command to call every 15 s.
|
||||
pub struct ProcessLister {
|
||||
system: System,
|
||||
}
|
||||
|
||||
impl Default for ProcessLister {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessLister {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
system: System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the process table in place and return the current
|
||||
/// lowercased executable names.
|
||||
pub fn snapshot(&mut self) -> Vec<String> {
|
||||
self.system
|
||||
.refresh_processes(ProcessesToUpdate::All, true);
|
||||
self.system
|
||||
.processes()
|
||||
.values()
|
||||
.map(|process| process.name().to_string_lossy().to_lowercase())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the current process list's executable/command names. Lowercased
|
||||
/// for case-insensitive pattern matching.
|
||||
///
|
||||
/// Convenience wrapper that allocates a fresh `ProcessLister` per call.
|
||||
/// Hot paths (the meeting-detection poller) should hold a long-lived
|
||||
/// `ProcessLister` and call `snapshot()` directly to avoid the per-call
|
||||
/// allocation of `System`'s internal bookkeeping.
|
||||
pub fn list_running_process_names() -> Vec<String> {
|
||||
let mut system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
|
||||
);
|
||||
system.refresh_processes(ProcessesToUpdate::All, true);
|
||||
system
|
||||
.processes()
|
||||
.values()
|
||||
.map(|process| process.name().to_string_lossy().to_lowercase())
|
||||
.collect()
|
||||
ProcessLister::new().snapshot()
|
||||
}
|
||||
|
||||
/// Match a snapshot of process names against case-insensitive substring
|
||||
|
||||
@@ -184,7 +184,7 @@ mod tests {
|
||||
fn parakeet_is_top_recommendation_when_hardware_supports_it() {
|
||||
// Any machine that fits Parakeet in RAM should see it ranked first —
|
||||
// Parakeet-TDT is English-only but beats Whisper on English at lower
|
||||
// latency, so it's Kon's default recommendation when eligible.
|
||||
// latency, so it's Magnotia's default recommendation when eligible.
|
||||
// (Users on non-English languages adjust manually — handled at the
|
||||
// settings-UI level, not at the scoring level for now.)
|
||||
let profile = profile_with_ram(Megabytes(16384));
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "kon-hotkey"
|
||||
name = "magnotia-hotkey"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Wayland-compatible global hotkey listener for Kon — evdev backend with device hotplug"
|
||||
description = "Wayland-compatible global hotkey listener for Magnotia — evdev backend with device hotplug"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
magnotia-core = { path = "../core" }
|
||||
tokio = { version = "1", features = ["rt", "sync", "macros", "time"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
log = "0.4"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Wayland-compatible global hotkey listener for Kon.
|
||||
//! Wayland-compatible global hotkey listener for Magnotia.
|
||||
//!
|
||||
//! On Linux, reads `/dev/input/event*` devices via the `evdev` crate to capture
|
||||
//! global hotkeys without any display-server dependency. This works on both X11
|
||||
@@ -8,7 +8,7 @@
|
||||
//! On non-Linux platforms, this crate is a no-op — the Tauri global-shortcut
|
||||
//! plugin handles hotkeys there.
|
||||
//!
|
||||
//! Architecture stolen from oddlama/whisper-overlay and adapted for Kon.
|
||||
//! Architecture stolen from oddlama/whisper-overlay and adapted for Magnotia.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
|
||||
@@ -89,7 +89,7 @@ impl EvdevHotkeyListener {
|
||||
Ok(()) => Some(w),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[kon-hotkey] cannot watch /dev/input ({e}); \
|
||||
"[magnotia-hotkey] cannot watch /dev/input ({e}); \
|
||||
hotplug detection disabled, devices present \
|
||||
at startup still work",
|
||||
);
|
||||
@@ -99,7 +99,7 @@ impl EvdevHotkeyListener {
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[kon-hotkey] cannot create inotify watcher ({e}); \
|
||||
"[magnotia-hotkey] cannot create inotify watcher ({e}); \
|
||||
hotplug detection disabled",
|
||||
);
|
||||
None
|
||||
@@ -317,10 +317,26 @@ async fn device_listener(
|
||||
&& alt_held == combo.alt
|
||||
&& super_held == combo.super_key
|
||||
{
|
||||
if pressed {
|
||||
let _ = event_tx.send(HotkeyEvent::Pressed).await;
|
||||
let to_send = if pressed {
|
||||
Some(HotkeyEvent::Pressed)
|
||||
} else if released {
|
||||
let _ = event_tx.send(HotkeyEvent::Released).await;
|
||||
Some(HotkeyEvent::Released)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(event) = to_send {
|
||||
if event_tx.send(event).await.is_err() {
|
||||
// Receiver was dropped without an
|
||||
// explicit None-on-hotkey-rx
|
||||
// shutdown. Log once and exit so
|
||||
// the listener doesn't spin
|
||||
// sending into a closed channel.
|
||||
log::warn!(
|
||||
"Hotkey event channel closed; \
|
||||
listener for device exiting"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,14 +359,14 @@ async fn device_listener(
|
||||
fn is_event_device(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map_or(false, |n| n.starts_with("event"))
|
||||
.is_some_and(|n| n.starts_with("event"))
|
||||
}
|
||||
|
||||
/// Return true when the device's reported key set includes the combo's
|
||||
/// configured trigger key. A device that reports no keys at all (for
|
||||
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
|
||||
fn device_supports_combo(supported: Option<&AttributeSetRef<Key>>, combo: &HotkeyCombo) -> bool {
|
||||
supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code)))
|
||||
supported.is_some_and(|keys| keys.contains(Key::new(combo.key_code)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
[package]
|
||||
name = "kon-llm"
|
||||
name = "magnotia-llm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Local LLM engine for Magnotia (Qwen3.5 / Qwen3.6 via llama-cpp-2): transcript cleanup, task extraction, micro-step decomposition"
|
||||
|
||||
[features]
|
||||
# Default desktop build keeps the existing openmp + vulkan acceleration.
|
||||
# Mobile / CPU-only targets can drop one or both via:
|
||||
# cargo build -p magnotia-llm --no-default-features
|
||||
# These are independent so an Android Vulkan build can opt into vulkan
|
||||
# without openmp (the NDK ships OpenMP libs but the toolchain configuration
|
||||
# is fragile across NDK versions).
|
||||
default = ["gpu-vulkan", "openmp"]
|
||||
gpu-vulkan = ["llama-cpp-2/vulkan"]
|
||||
openmp = ["llama-cpp-2/openmp"]
|
||||
|
||||
[dependencies]
|
||||
dirs = "6"
|
||||
magnotia-core = { path = "../core" }
|
||||
encoding_rs = "0.8"
|
||||
futures-util = "0.3"
|
||||
llama-cpp-2 = { version = "0.1.144", default-features = false, features = ["openmp", "vulkan"] }
|
||||
num_cpus = "1"
|
||||
llama-cpp-2 = { version = "0.1.144", default-features = false }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
// Phase 9 content-tag extraction. Restricts the model output to a
|
||||
// strict {topic, intent} JSON object where topic is a lowercase
|
||||
// hyphen-joined slug of at least 3 chars (no upper bound is encoded
|
||||
// in the grammar — max_tokens caps it in practice) and intent is one
|
||||
// of the six closed-set values. Recursive `topic-rest` keeps the
|
||||
// shape compatible with the existing GBNF style in this file.
|
||||
pub const CONTENT_TAGS_GRAMMAR: &str = r##"
|
||||
root ::= "{" ws "\"topic\":" ws topic-str ws "," ws "\"intent\":" ws intent ws "}" ws
|
||||
topic-str ::= "\"" topic-char topic-char topic-char topic-rest "\""
|
||||
topic-rest ::= "" | topic-char topic-rest
|
||||
topic-char ::= [a-z0-9-]
|
||||
intent ::= "\"planning\"" | "\"reflection\"" | "\"venting\"" | "\"capture\"" | "\"decision\"" | "\"question\""
|
||||
ws ::= ([ \t\n] ws)?
|
||||
"##;
|
||||
|
||||
pub const TASK_ARRAY_GRAMMAR: &str = r#"
|
||||
root ::= "[" ws string ws "," ws string ws "," ws string rest3 ws "]"
|
||||
rest3 ::= "" | "," ws string rest4
|
||||
|
||||
@@ -15,7 +15,9 @@ pub mod grammars;
|
||||
pub mod model_manager;
|
||||
pub mod prompts;
|
||||
|
||||
pub use grammars::CONTENT_TAGS_GRAMMAR;
|
||||
pub use model_manager::{recommend_tier, LlmModelId, LlmModelInfo};
|
||||
pub use prompts::{is_valid_intent, ContentTags, CONTENT_TAGS_SYSTEM, INTENT_CLOSED_SET};
|
||||
|
||||
const DEFAULT_CONTEXT_TOKENS: u32 = 4096;
|
||||
const MAX_CONTEXT_TOKENS: u32 = 8192;
|
||||
@@ -161,7 +163,8 @@ impl LlmEngine {
|
||||
}
|
||||
|
||||
let n_ctx = preflight_context_window(prompt_tokens.len(), config.max_tokens)?;
|
||||
let thread_count = i32::try_from(num_cpus::get().max(1)).unwrap_or(4);
|
||||
let thread_count = i32::try_from(magnotia_core::constants::inference_thread_count())
|
||||
.unwrap_or(4);
|
||||
let ctx_params = LlamaContextParams::default()
|
||||
.with_n_ctx(Some(
|
||||
NonZeroU32::new(n_ctx).expect("n_ctx must be non-zero"),
|
||||
@@ -240,11 +243,30 @@ impl LlmEngine {
|
||||
}
|
||||
|
||||
pub fn decompose_task(&self, task_text: &str) -> Result<Vec<String>, EngineError> {
|
||||
self.decompose_task_with_feedback(task_text, &[])
|
||||
}
|
||||
|
||||
/// Same as `decompose_task` but allows callers to pass recent HITL
|
||||
/// feedback rows so the system prompt gets conditioned on the
|
||||
/// user's preferred decomposition style. The `examples` vec is
|
||||
/// rendered into a few-shot block appended to the base system
|
||||
/// prompt by `prompts::build_conditioned_system_prompt`.
|
||||
///
|
||||
/// Callers should pass most-recent-first; older examples still
|
||||
/// participate but weigh less because of their position in the
|
||||
/// prompt. Empty slice keeps behaviour identical to `decompose_task`.
|
||||
pub fn decompose_task_with_feedback(
|
||||
&self,
|
||||
task_text: &str,
|
||||
examples: &[prompts::FeedbackExample],
|
||||
) -> Result<Vec<String>, EngineError> {
|
||||
let model = self.loaded_model_arc()?;
|
||||
let system =
|
||||
prompts::build_conditioned_system_prompt(prompts::DECOMPOSE_TASK_SYSTEM, examples);
|
||||
let prompt = render_chat_prompt(
|
||||
&model,
|
||||
&[
|
||||
("system", prompts::DECOMPOSE_TASK_SYSTEM),
|
||||
("system", system.as_str()),
|
||||
("user", &format!("Task: {task_text}")),
|
||||
],
|
||||
)?;
|
||||
@@ -261,15 +283,85 @@ impl LlmEngine {
|
||||
}
|
||||
|
||||
pub fn extract_tasks(&self, transcript: &str) -> Result<Vec<String>, EngineError> {
|
||||
self.extract_tasks_with_feedback(transcript, &[])
|
||||
}
|
||||
|
||||
/// Phase 9 content-tag extraction. Emits a single (topic, intent)
|
||||
/// pair under the `CONTENT_TAGS_GRAMMAR` GBNF. Truncates to the
|
||||
/// trailing 2000 chars of the transcript so the prompt budget
|
||||
/// stays well under any model's context window. Determinism is
|
||||
/// enforced by temperature 0.0 and the closed-set intent grammar
|
||||
/// rule; on the rare case the model emits a parse-able-but-out-of-
|
||||
/// set intent, we re-validate with `is_valid_intent` and bubble
|
||||
/// `InvalidJson` so the frontend toasts a clear error.
|
||||
pub fn extract_content_tags(
|
||||
&self,
|
||||
transcript: &str,
|
||||
) -> Result<prompts::ContentTags, EngineError> {
|
||||
if transcript.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
return Err(EngineError::Inference("empty transcript".into()));
|
||||
}
|
||||
|
||||
// Truncate to the last 2000 chars on a UTF-8 char boundary so
|
||||
// we don't slice through a multi-byte sequence.
|
||||
const MAX_CHARS: usize = 2000;
|
||||
let tail = if transcript.len() > MAX_CHARS {
|
||||
let mut adj = transcript.len() - MAX_CHARS;
|
||||
while adj < transcript.len() && !transcript.is_char_boundary(adj) {
|
||||
adj += 1;
|
||||
}
|
||||
&transcript[adj..]
|
||||
} else {
|
||||
transcript
|
||||
};
|
||||
|
||||
let model = self.loaded_model_arc()?;
|
||||
let prompt = render_chat_prompt(
|
||||
&model,
|
||||
&[
|
||||
("system", prompts::EXTRACT_TASKS_SYSTEM),
|
||||
("system", prompts::CONTENT_TAGS_SYSTEM),
|
||||
("user", &format!("Transcript:\n{tail}")),
|
||||
],
|
||||
)?;
|
||||
let raw = self.generate(
|
||||
&prompt,
|
||||
&GenerationConfig {
|
||||
max_tokens: 96,
|
||||
temperature: 0.0,
|
||||
stop_sequences: vec!["<|im_end|>".to_string(), "<|im_end_of_text|>".to_string()],
|
||||
grammar: Some(grammars::CONTENT_TAGS_GRAMMAR.to_string()),
|
||||
},
|
||||
)?;
|
||||
|
||||
let tags: prompts::ContentTags = serde_json::from_str(raw.trim())
|
||||
.map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))?;
|
||||
if !prompts::is_valid_intent(&tags.intent) {
|
||||
return Err(EngineError::InvalidJson(format!(
|
||||
"intent out of closed set: {}",
|
||||
tags.intent,
|
||||
)));
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Feedback-conditioned variant of `extract_tasks`. See
|
||||
/// `decompose_task_with_feedback` for the `examples` semantics.
|
||||
pub fn extract_tasks_with_feedback(
|
||||
&self,
|
||||
transcript: &str,
|
||||
examples: &[prompts::FeedbackExample],
|
||||
) -> Result<Vec<String>, EngineError> {
|
||||
if transcript.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let model = self.loaded_model_arc()?;
|
||||
let system =
|
||||
prompts::build_conditioned_system_prompt(prompts::EXTRACT_TASKS_SYSTEM, examples);
|
||||
let prompt = render_chat_prompt(
|
||||
&model,
|
||||
&[
|
||||
("system", system.as_str()),
|
||||
("user", &format!("Transcript:\n{transcript}")),
|
||||
],
|
||||
)?;
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::fmt;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -11,100 +12,119 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
#[allow(non_camel_case_types)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum LlmModelId {
|
||||
#[serde(rename = "qwen3_1_7b")]
|
||||
Qwen3_1_7B_Q4,
|
||||
#[serde(rename = "qwen3_4b_instruct_2507")]
|
||||
Qwen3_4BInstruct2507Q4,
|
||||
#[serde(rename = "qwen3_14b")]
|
||||
Qwen3_14BQ5,
|
||||
#[serde(rename = "qwen3_5_2b")]
|
||||
Qwen3_5_2B_Q4,
|
||||
#[serde(rename = "qwen3_5_4b")]
|
||||
Qwen3_5_4B_Q4,
|
||||
#[serde(rename = "qwen3_5_9b")]
|
||||
Qwen3_5_9B_Q4,
|
||||
#[serde(rename = "qwen3_6_27b")]
|
||||
Qwen3_6_27B_Q4,
|
||||
}
|
||||
|
||||
impl LlmModelId {
|
||||
pub fn default_tier() -> Self {
|
||||
Self::Qwen3_4BInstruct2507Q4
|
||||
Self::Qwen3_5_4B_Q4
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Qwen3_1_7B_Q4 => "qwen3_1_7b",
|
||||
Self::Qwen3_4BInstruct2507Q4 => "qwen3_4b_instruct_2507",
|
||||
Self::Qwen3_14BQ5 => "qwen3_14b",
|
||||
Self::Qwen3_5_2B_Q4 => "qwen3_5_2b",
|
||||
Self::Qwen3_5_4B_Q4 => "qwen3_5_4b",
|
||||
Self::Qwen3_5_9B_Q4 => "qwen3_5_9b",
|
||||
Self::Qwen3_6_27B_Q4 => "qwen3_6_27b",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Qwen3_1_7B_Q4 => "Qwen3 1.7B",
|
||||
Self::Qwen3_4BInstruct2507Q4 => "Qwen3 4B Instruct 2507",
|
||||
Self::Qwen3_14BQ5 => "Qwen3 14B",
|
||||
Self::Qwen3_5_2B_Q4 => "Qwen3.5 2B",
|
||||
Self::Qwen3_5_4B_Q4 => "Qwen3.5 4B",
|
||||
Self::Qwen3_5_9B_Q4 => "Qwen3.5 9B",
|
||||
Self::Qwen3_6_27B_Q4 => "Qwen3.6 27B",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Qwen3_1_7B_Q4 => "Qwen3-1.7B-Q4_K_M.gguf",
|
||||
Self::Qwen3_4BInstruct2507Q4 => "Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
||||
Self::Qwen3_14BQ5 => "Qwen3-14B-Q5_K_M.gguf",
|
||||
Self::Qwen3_5_2B_Q4 => "Qwen3.5-2B-Q4_K_M.gguf",
|
||||
Self::Qwen3_5_4B_Q4 => "Qwen3.5-4B-Q4_K_M.gguf",
|
||||
Self::Qwen3_5_9B_Q4 => "Qwen3.5-9B-Q4_K_M.gguf",
|
||||
Self::Qwen3_6_27B_Q4 => "Qwen3.6-27B-Q4_K_M.gguf",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size_bytes(&self) -> u64 {
|
||||
match self {
|
||||
Self::Qwen3_1_7B_Q4 => 1_107_409_472,
|
||||
Self::Qwen3_4BInstruct2507Q4 => 2_497_281_120,
|
||||
Self::Qwen3_14BQ5 => 10_514_570_624,
|
||||
Self::Qwen3_5_2B_Q4 => 1_280_835_840,
|
||||
Self::Qwen3_5_4B_Q4 => 2_740_937_888,
|
||||
Self::Qwen3_5_9B_Q4 => 5_680_522_464,
|
||||
Self::Qwen3_6_27B_Q4 => 16_817_244_384,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn minimum_ram_bytes(&self) -> u64 {
|
||||
match self {
|
||||
Self::Qwen3_1_7B_Q4 => 8 * 1024_u64.pow(3),
|
||||
Self::Qwen3_4BInstruct2507Q4 => 16 * 1024_u64.pow(3),
|
||||
Self::Qwen3_14BQ5 => 32 * 1024_u64.pow(3),
|
||||
Self::Qwen3_5_2B_Q4 => 8 * 1024_u64.pow(3),
|
||||
Self::Qwen3_5_4B_Q4 => 16 * 1024_u64.pow(3),
|
||||
Self::Qwen3_5_9B_Q4 => 32 * 1024_u64.pow(3),
|
||||
Self::Qwen3_6_27B_Q4 => 64 * 1024_u64.pow(3),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recommended_vram_bytes(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Qwen3_1_7B_Q4 => None,
|
||||
Self::Qwen3_4BInstruct2507Q4 => Some(8 * 1024_u64.pow(3)),
|
||||
Self::Qwen3_14BQ5 => Some(16 * 1024_u64.pow(3)),
|
||||
Self::Qwen3_5_2B_Q4 => None,
|
||||
Self::Qwen3_5_4B_Q4 => Some(6 * 1024_u64.pow(3)),
|
||||
Self::Qwen3_5_9B_Q4 => Some(12 * 1024_u64.pow(3)),
|
||||
Self::Qwen3_6_27B_Q4 => Some(24 * 1024_u64.pow(3)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Qwen3_1_7B_Q4 => "Low tier for 8 GB RAM and CPU-heavy machines.",
|
||||
Self::Qwen3_4BInstruct2507Q4 => {
|
||||
"Default tier for cleanup and task extraction on 16 GB systems."
|
||||
Self::Qwen3_5_2B_Q4 => "Minimal tier for 8 GB RAM and CPU-heavy machines.",
|
||||
Self::Qwen3_5_4B_Q4 => {
|
||||
"Standard tier for cleanup and task extraction on 16 GB systems."
|
||||
}
|
||||
Self::Qwen3_5_9B_Q4 => "High tier for 32 GB RAM with a 12 GB+ GPU.",
|
||||
Self::Qwen3_6_27B_Q4 => {
|
||||
"Maximum tier for 64 GB RAM with a 24 GB GPU; partial CPU offload below that."
|
||||
}
|
||||
Self::Qwen3_14BQ5 => "High tier for 32 GB+ RAM and larger GPUs.",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hf_url(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Qwen3_1_7B_Q4 => {
|
||||
"https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/d7f544eead698dbd1f15126ef60b45a1e1933222/Qwen3-1.7B-Q4_K_M.gguf"
|
||||
Self::Qwen3_5_2B_Q4 => {
|
||||
"https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/f6d5376be1edb4d416d56da11e5397a961aca8ae/Qwen3.5-2B-Q4_K_M.gguf"
|
||||
}
|
||||
Self::Qwen3_4BInstruct2507Q4 => {
|
||||
"https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/a06e946bb6b655725eafa393f4a9745d460374c9/Qwen3-4B-Instruct-2507-Q4_K_M.gguf"
|
||||
Self::Qwen3_5_4B_Q4 => {
|
||||
"https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/e87f176479d0855a907a41277aca2f8ee7a09523/Qwen3.5-4B-Q4_K_M.gguf"
|
||||
}
|
||||
Self::Qwen3_14BQ5 => {
|
||||
"https://huggingface.co/unsloth/Qwen3-14B-GGUF/resolve/a04a82c4739b3ef5fa6da7d10261db2c67dd1985/Qwen3-14B-Q5_K_M.gguf"
|
||||
Self::Qwen3_5_9B_Q4 => {
|
||||
"https://huggingface.co/unsloth/Qwen3.5-9B-GGUF/resolve/3885219b6810b007914f3a7950a8d1b469d598a5/Qwen3.5-9B-Q4_K_M.gguf"
|
||||
}
|
||||
Self::Qwen3_6_27B_Q4 => {
|
||||
"https://huggingface.co/unsloth/Qwen3.6-27B-GGUF/resolve/82d411acf4a06cfb8d9b073a5211bf410bfc29bf/Qwen3.6-27B-Q4_K_M.gguf"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sha256(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Qwen3_1_7B_Q4 => {
|
||||
"de942b0819216caa3bfe487180dd1bb37398fa1c98cb42bb0bbac7ab7d6e8a12"
|
||||
Self::Qwen3_5_2B_Q4 => {
|
||||
"aaf42c8b7c3cab2bf3d69c355048d4a0ee9973d48f16c731c0520ee914699223"
|
||||
}
|
||||
Self::Qwen3_4BInstruct2507Q4 => {
|
||||
"bf52d44a54b81d44219833556849529ee96f09da673a38783dddc2e2eaf17881"
|
||||
Self::Qwen3_5_4B_Q4 => {
|
||||
"00fe7986ff5f6b463e62455821146049db6f9313603938a70800d1fb69ef11a4"
|
||||
}
|
||||
Self::Qwen3_5_9B_Q4 => {
|
||||
"03b74727a860a56338e042c4420bb3f04b2fec5734175f4cb9fa853daf52b7e8"
|
||||
}
|
||||
Self::Qwen3_6_27B_Q4 => {
|
||||
"5ed60d0af4650a854b1755bd392f9aef4872643dc25a254bc68043fa638392a0"
|
||||
}
|
||||
Self::Qwen3_14BQ5 => "6f87abc471bd509ad46aca4284b3cfa926d8114bc491bb0a7a3a7f74c16ef95b",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,9 +140,10 @@ impl FromStr for LlmModelId {
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"qwen3_1_7b" => Ok(Self::Qwen3_1_7B_Q4),
|
||||
"qwen3_4b_instruct_2507" => Ok(Self::Qwen3_4BInstruct2507Q4),
|
||||
"qwen3_14b" => Ok(Self::Qwen3_14BQ5),
|
||||
"qwen3_5_2b" => Ok(Self::Qwen3_5_2B_Q4),
|
||||
"qwen3_5_4b" => Ok(Self::Qwen3_5_4B_Q4),
|
||||
"qwen3_5_9b" => Ok(Self::Qwen3_5_9B_Q4),
|
||||
"qwen3_6_27b" => Ok(Self::Qwen3_6_27B_Q4),
|
||||
other => Err(format!("Unknown LLM model id: {other}")),
|
||||
}
|
||||
}
|
||||
@@ -153,11 +174,42 @@ pub enum DownloadError {
|
||||
}
|
||||
|
||||
const ALL_MODELS: &[LlmModelId] = &[
|
||||
LlmModelId::Qwen3_1_7B_Q4,
|
||||
LlmModelId::Qwen3_4BInstruct2507Q4,
|
||||
LlmModelId::Qwen3_14BQ5,
|
||||
LlmModelId::Qwen3_5_2B_Q4,
|
||||
LlmModelId::Qwen3_5_4B_Q4,
|
||||
LlmModelId::Qwen3_5_9B_Q4,
|
||||
LlmModelId::Qwen3_6_27B_Q4,
|
||||
];
|
||||
|
||||
static ACTIVE_DOWNLOADS: LazyLock<Mutex<std::collections::HashSet<LlmModelId>>> =
|
||||
LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
|
||||
|
||||
struct DownloadReservation {
|
||||
id: LlmModelId,
|
||||
}
|
||||
|
||||
impl DownloadReservation {
|
||||
fn acquire(id: LlmModelId) -> Result<Self, DownloadError> {
|
||||
let mut active = ACTIVE_DOWNLOADS
|
||||
.lock()
|
||||
.map_err(|_| DownloadError::Http("download lock poisoned".into()))?;
|
||||
if !active.insert(id) {
|
||||
return Err(DownloadError::Http(format!(
|
||||
"download already in progress for {}",
|
||||
id.as_str()
|
||||
)));
|
||||
}
|
||||
Ok(Self { id })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DownloadReservation {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut active) = ACTIVE_DOWNLOADS.lock() {
|
||||
active.remove(&self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_models() -> &'static [LlmModelId] {
|
||||
ALL_MODELS
|
||||
}
|
||||
@@ -175,34 +227,20 @@ pub fn model_info(id: LlmModelId) -> LlmModelInfo {
|
||||
}
|
||||
|
||||
pub fn recommend_tier(total_ram_bytes: u64, total_vram_bytes: Option<u64>) -> LlmModelId {
|
||||
if total_vram_bytes.unwrap_or(0) >= 16 * 1024_u64.pow(3)
|
||||
&& total_ram_bytes >= 32 * 1024_u64.pow(3)
|
||||
{
|
||||
LlmModelId::Qwen3_14BQ5
|
||||
} else if total_vram_bytes.unwrap_or(0) >= 8 * 1024_u64.pow(3)
|
||||
|| total_ram_bytes >= 16 * 1024_u64.pow(3)
|
||||
{
|
||||
LlmModelId::Qwen3_4BInstruct2507Q4
|
||||
let vram = total_vram_bytes.unwrap_or(0);
|
||||
if vram >= 24 * 1024_u64.pow(3) && total_ram_bytes >= 64 * 1024_u64.pow(3) {
|
||||
LlmModelId::Qwen3_6_27B_Q4
|
||||
} else if vram >= 12 * 1024_u64.pow(3) && total_ram_bytes >= 32 * 1024_u64.pow(3) {
|
||||
LlmModelId::Qwen3_5_9B_Q4
|
||||
} else if vram >= 6 * 1024_u64.pow(3) || total_ram_bytes >= 16 * 1024_u64.pow(3) {
|
||||
LlmModelId::Qwen3_5_4B_Q4
|
||||
} else {
|
||||
LlmModelId::Qwen3_1_7B_Q4
|
||||
LlmModelId::Qwen3_5_2B_Q4
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_dir() -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
std::env::var("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join("kon")
|
||||
.join("models")
|
||||
.join("llm")
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".kon")
|
||||
.join("models")
|
||||
.join("llm")
|
||||
}
|
||||
magnotia_core::paths::app_paths().llm_models_dir()
|
||||
}
|
||||
|
||||
pub fn model_path(id: LlmModelId) -> PathBuf {
|
||||
@@ -235,6 +273,7 @@ pub async fn download_model<F>(id: LlmModelId, on_progress: F) -> Result<(), Dow
|
||||
where
|
||||
F: FnMut(u64, u64) + Send + 'static,
|
||||
{
|
||||
let _reservation = DownloadReservation::acquire(id)?;
|
||||
let dest = model_path(id);
|
||||
tokio::fs::create_dir_all(model_dir()).await?;
|
||||
|
||||
@@ -282,7 +321,7 @@ where
|
||||
.unwrap_or(0);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("kon/0.1.0")
|
||||
.user_agent("magnotia/0.1.0")
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| DownloadError::Http(e.to_string()))?;
|
||||
@@ -370,15 +409,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn model_path_contains_model_dir_and_filename() {
|
||||
let path = model_path(LlmModelId::Qwen3_1_7B_Q4);
|
||||
assert!(path.to_string_lossy().ends_with("Qwen3-1.7B-Q4_K_M.gguf"));
|
||||
let path = model_path(LlmModelId::Qwen3_5_2B_Q4);
|
||||
assert!(path.to_string_lossy().ends_with("Qwen3.5-2B-Q4_K_M.gguf"));
|
||||
assert!(path.starts_with(model_dir()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_tier_prefers_mid_by_default() {
|
||||
let tier = recommend_tier(16 * 1024_u64.pow(3), None);
|
||||
assert_eq!(tier, LlmModelId::Qwen3_4BInstruct2507Q4);
|
||||
assert_eq!(tier, LlmModelId::Qwen3_5_4B_Q4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -4,9 +4,152 @@ between 3 and 7 concrete, physical micro-steps. Each step must be a short \
|
||||
imperative sentence, actionable today, with no commentary. Output ONLY a \
|
||||
JSON array of strings.";
|
||||
|
||||
// Phase 9 content-tag extraction. The model emits a {topic, intent}
|
||||
// JSON pair under a strict GBNF (see grammars::CONTENT_TAGS_GRAMMAR).
|
||||
// CONTENT_TAGS_SYSTEM is the system message; the user message wraps
|
||||
// the transcript text.
|
||||
pub const CONTENT_TAGS_SYSTEM: &str = "\
|
||||
You tag a transcript with ONE topic and ONE intent. \
|
||||
TOPIC is a 1 to 3 token lowercase hyphen-joined noun phrase naming the \
|
||||
dominant subject. Examples: interview-prep, grant-application, \
|
||||
daily-standup. \
|
||||
INTENT is exactly one of: planning, reflection, venting, capture, \
|
||||
decision, question. \
|
||||
Return JSON only, with this exact shape: \
|
||||
{\"topic\":\"...\",\"intent\":\"...\"}";
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ContentTags {
|
||||
pub topic: String,
|
||||
pub intent: String,
|
||||
}
|
||||
|
||||
pub const INTENT_CLOSED_SET: &[&str] = &[
|
||||
"planning",
|
||||
"reflection",
|
||||
"venting",
|
||||
"capture",
|
||||
"decision",
|
||||
"question",
|
||||
];
|
||||
|
||||
pub fn is_valid_intent(s: &str) -> bool {
|
||||
INTENT_CLOSED_SET.contains(&s)
|
||||
}
|
||||
|
||||
pub const EXTRACT_TASKS_SYSTEM: &str = "\
|
||||
You are a task-extraction assistant. Given a transcript of spoken notes, \
|
||||
output a JSON array of action items the speaker committed to. Each item must \
|
||||
be a short imperative sentence. Omit observations, wishes, and background \
|
||||
context that are not explicit commitments. Output an empty array if there are \
|
||||
no action items.";
|
||||
|
||||
/// Compact representation of a human-in-the-loop feedback example used
|
||||
/// for few-shot prompt conditioning. Built by magnotia-storage and fed to the
|
||||
/// prompt builder below; we keep this struct local to the LLM crate so
|
||||
/// magnotia-llm does not depend on magnotia-storage.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeedbackExample {
|
||||
/// What the AI was given as input (e.g. the parent task text, or
|
||||
/// the transcript chunk). Kept verbatim.
|
||||
pub input: String,
|
||||
/// What the AI produced originally. `None` if the user only
|
||||
/// gave a thumbs-up without a prior edit (positive signal
|
||||
/// without a paired correction).
|
||||
pub original_output: Option<String>,
|
||||
/// What the user changed it to. `None` for thumbs-only rows.
|
||||
/// This is the highest-value signal — when present, inject it
|
||||
/// as the "good" output in the few-shot example.
|
||||
pub corrected_output: Option<String>,
|
||||
}
|
||||
|
||||
/// Render a feedback example into the exemplar block used in prompt
|
||||
/// conditioning. Returns `None` for rows that carry no usable pairing
|
||||
/// (e.g. a thumbs-up with no input context).
|
||||
fn render_feedback_exemplar(ex: &FeedbackExample) -> Option<String> {
|
||||
if ex.input.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let good = ex
|
||||
.corrected_output
|
||||
.as_deref()
|
||||
.or(ex.original_output.as_deref())?;
|
||||
let good = good.trim();
|
||||
if good.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("Input: {}\nGood output: {}", ex.input.trim(), good))
|
||||
}
|
||||
|
||||
/// Build a system prompt that combines the base task system prompt
|
||||
/// with a few-shot block assembled from recent HITL examples. If no
|
||||
/// usable examples are available, returns the base prompt unchanged
|
||||
/// so early users see the generic behaviour and the LLM is not
|
||||
/// confused by an empty exemplar section.
|
||||
///
|
||||
/// The exemplars are ordered most-recent-first (caller's order is
|
||||
/// preserved) so the LLM weights the user's current style over
|
||||
/// earlier noise, mirroring what a human reviewer would do.
|
||||
pub fn build_conditioned_system_prompt(base: &str, examples: &[FeedbackExample]) -> String {
|
||||
let rendered: Vec<String> = examples
|
||||
.iter()
|
||||
.filter_map(render_feedback_exemplar)
|
||||
.collect();
|
||||
if rendered.is_empty() {
|
||||
return base.to_string();
|
||||
}
|
||||
let block = rendered
|
||||
.iter()
|
||||
.map(|s| format!("- {s}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!(
|
||||
"{base}\n\nHere are examples of the style this user prefers, in the \
|
||||
user's own words. Match this style closely when producing your output:\n{block}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_plain_prompt_when_no_examples() {
|
||||
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &[]);
|
||||
assert_eq!(out, DECOMPOSE_TASK_SYSTEM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_empty_input_examples() {
|
||||
let examples = vec![FeedbackExample {
|
||||
input: String::new(),
|
||||
original_output: None,
|
||||
corrected_output: Some("ignored".into()),
|
||||
}];
|
||||
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &examples);
|
||||
assert_eq!(out, DECOMPOSE_TASK_SYSTEM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_corrected_over_original() {
|
||||
let examples = vec![FeedbackExample {
|
||||
input: "Clean room".into(),
|
||||
original_output: Some("Organise your bedroom".into()),
|
||||
corrected_output: Some("Pick up one shirt from the floor".into()),
|
||||
}];
|
||||
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &examples);
|
||||
assert!(out.contains("Pick up one shirt from the floor"));
|
||||
assert!(!out.contains("Organise your bedroom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_original_when_no_correction() {
|
||||
let examples = vec![FeedbackExample {
|
||||
input: "Write report".into(),
|
||||
original_output: Some("Open a blank document".into()),
|
||||
corrected_output: None,
|
||||
}];
|
||||
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &examples);
|
||||
assert!(out.contains("Open a blank document"));
|
||||
}
|
||||
}
|
||||
|
||||
48
crates/llm/tests/content_tags_smoke.rs
Normal file
48
crates/llm/tests/content_tags_smoke.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! Smoke test for Phase 9 LlmEngine::extract_content_tags.
|
||||
//!
|
||||
//! Gated behind the same `MAGNOTIA_LLM_TEST_MODEL` env var as the existing
|
||||
//! smoke.rs test so neither runs in default `cargo test` runs (model
|
||||
//! load is heavy). Run explicitly with:
|
||||
//!
|
||||
//! MAGNOTIA_LLM_TEST_MODEL=/path/to/model.gguf cargo test -p magnotia-llm \
|
||||
//! --test content_tags_smoke -- --nocapture
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use magnotia_llm::{is_valid_intent, LlmEngine, LlmModelId};
|
||||
|
||||
#[test]
|
||||
fn extract_content_tags_returns_valid_pair() {
|
||||
let model_path = match env::var("MAGNOTIA_LLM_TEST_MODEL") {
|
||||
Ok(path) => PathBuf::from(path),
|
||||
Err(_) => {
|
||||
eprintln!("MAGNOTIA_LLM_TEST_MODEL not set — skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let engine = LlmEngine::new();
|
||||
engine
|
||||
.load_model(LlmModelId::Qwen3_5_2B_Q4, &model_path, true)
|
||||
.expect("load model");
|
||||
|
||||
let transcript = "Tomorrow I need to run through the grant application one more time \
|
||||
and make sure the figures add up. I also need to book a slot with \
|
||||
Rachmann for the Mac test and email Andrew about the meeting window.";
|
||||
let tags = engine
|
||||
.extract_content_tags(transcript)
|
||||
.expect("extract_content_tags");
|
||||
|
||||
assert!(tags.topic.len() >= 3, "topic present: {tags:?}");
|
||||
assert!(
|
||||
tags.topic
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
|
||||
"topic lowercase + slugged: {tags:?}",
|
||||
);
|
||||
assert!(
|
||||
is_valid_intent(&tags.intent),
|
||||
"intent in closed set: {tags:?}",
|
||||
);
|
||||
}
|
||||
@@ -6,33 +6,33 @@
|
||||
//! - `context::params::LlamaContextParams`
|
||||
//! - `sampling::LlamaSampler`
|
||||
//!
|
||||
//! The test is gated behind `KON_LLM_TEST_MODEL`.
|
||||
//! The test is gated behind `MAGNOTIA_LLM_TEST_MODEL`.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use kon_llm::LlmEngine;
|
||||
use kon_llm::LlmModelId;
|
||||
use magnotia_llm::LlmEngine;
|
||||
use magnotia_llm::LlmModelId;
|
||||
|
||||
#[test]
|
||||
fn llama_cpp_2_smoke_generates_and_wraps() {
|
||||
let model_path = match env::var("KON_LLM_TEST_MODEL") {
|
||||
let model_path = match env::var("MAGNOTIA_LLM_TEST_MODEL") {
|
||||
Ok(path) => PathBuf::from(path),
|
||||
Err(_) => {
|
||||
eprintln!("KON_LLM_TEST_MODEL not set — skipping");
|
||||
eprintln!("MAGNOTIA_LLM_TEST_MODEL not set — skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let engine = LlmEngine::new();
|
||||
engine
|
||||
.load_model(LlmModelId::Qwen3_1_7B_Q4, &model_path, true)
|
||||
.load_model(LlmModelId::Qwen3_5_2B_Q4, &model_path, true)
|
||||
.expect("load model");
|
||||
|
||||
let completion = engine
|
||||
.generate(
|
||||
"Write exactly one short greeting.",
|
||||
&kon_llm::GenerationConfig {
|
||||
&magnotia_llm::GenerationConfig {
|
||||
max_tokens: 32,
|
||||
temperature: 0.0,
|
||||
stop_sequences: vec!["\n".to_string()],
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
[package]
|
||||
name = "kon-mcp"
|
||||
name = "magnotia-mcp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Read-only MCP stdio server exposing Kon transcripts and tasks to external agents"
|
||||
description = "Read-only MCP stdio server exposing Magnotia transcripts and tasks to external agents"
|
||||
|
||||
[[bin]]
|
||||
name = "kon-mcp"
|
||||
name = "magnotia-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
kon-storage = { path = "../storage" }
|
||||
magnotia-storage = { path = "../storage" }
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Minimal Model Context Protocol server exposing Kon's local SQLite store.
|
||||
//! Minimal Model Context Protocol server exposing Magnotia's local SQLite store.
|
||||
//!
|
||||
//! Scope: **read-only** tools. An external agent (Claude desktop, Cline, any
|
||||
//! MCP-capable client) can list / search / fetch transcripts and list tasks.
|
||||
//! No writes — Kon's Tauri app remains the only writer.
|
||||
//! No writes — Magnotia's Tauri app remains the only writer.
|
||||
//!
|
||||
//! Transport: newline-delimited JSON-RPC 2.0 over stdio, per the stdio
|
||||
//! transport spec. Server spec version: 2024-11-05.
|
||||
@@ -12,7 +12,7 @@ use serde_json::{json, Value};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub const PROTOCOL_VERSION: &str = "2024-11-05";
|
||||
pub const SERVER_NAME: &str = "kon-mcp";
|
||||
pub const SERVER_NAME: &str = "magnotia-mcp";
|
||||
pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -95,7 +95,7 @@ fn initialize_result() -> Value {
|
||||
"version": SERVER_VERSION,
|
||||
},
|
||||
"instructions":
|
||||
"Read-only access to Kon's local transcript history and task list. \
|
||||
"Read-only access to Magnotia's local transcript history and task list. \
|
||||
All data stays on the user's machine.",
|
||||
})
|
||||
}
|
||||
@@ -105,7 +105,7 @@ fn tools_list_result() -> Value {
|
||||
"tools": [
|
||||
{
|
||||
"name": "list_transcripts",
|
||||
"description": "List recent transcripts from Kon's local history, most recent first. \
|
||||
"description": "List recent transcripts from Magnotia's local history, most recent first. \
|
||||
Returns summaries (id, title, created_at, duration, preview).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
@@ -135,7 +135,7 @@ fn tools_list_result() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "search_transcripts",
|
||||
"description": "Full-text search across Kon's transcripts. Returns matching summaries.",
|
||||
"description": "Full-text search across Magnotia's transcripts. Returns matching summaries.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
@@ -155,7 +155,7 @@ fn tools_list_result() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "list_tasks",
|
||||
"description": "List tasks from Kon's task store. Returns both open and completed.",
|
||||
"description": "List tasks from Magnotia's task store. Returns both open and completed.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@@ -206,7 +206,7 @@ async fn list_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value,
|
||||
};
|
||||
let limit = args.limit.unwrap_or(20).clamp(1, 200);
|
||||
|
||||
let rows = kon_storage::list_transcripts(pool, limit)
|
||||
let rows = magnotia_storage::list_transcripts(pool, limit)
|
||||
.await
|
||||
.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)
|
||||
.map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?;
|
||||
|
||||
let row = kon_storage::get_transcript(pool, &args.id)
|
||||
let row = magnotia_storage::get_transcript(pool, &args.id)
|
||||
.await
|
||||
.map_err(|e| error(-32603, format!("DB error: {e}")))?
|
||||
.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}")))?;
|
||||
let limit = args.limit.unwrap_or(20).clamp(1, 100);
|
||||
|
||||
let rows = kon_storage::search_transcripts(pool, &args.query, limit)
|
||||
let rows = magnotia_storage::search_transcripts(pool, &args.query, limit)
|
||||
.await
|
||||
.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> {
|
||||
let rows = kon_storage::list_tasks(pool)
|
||||
let rows = magnotia_storage::list_tasks(pool)
|
||||
.await
|
||||
.map_err(|e| error(-32603, format!("DB error: {e}")))?;
|
||||
|
||||
@@ -460,7 +460,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
kon_storage::migrations::run_migrations(&pool)
|
||||
magnotia_storage::migrations::run_migrations(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = handle_message(&pool, request).await.expect("has response");
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
//! Stdio entry point for kon-mcp. Reads newline-delimited JSON-RPC messages
|
||||
//! from stdin, dispatches via `kon_mcp::handle_message`, writes responses to
|
||||
//! Stdio entry point for magnotia-mcp. Reads newline-delimited JSON-RPC messages
|
||||
//! from stdin, dispatches via `magnotia_mcp::handle_message`, writes responses to
|
||||
//! stdout. Logs land on stderr so they don't collide with the JSON-RPC stream.
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let db_path = kon_storage::database_path();
|
||||
eprintln!("[kon-mcp] opening Kon database at {}", db_path.display());
|
||||
let pool = kon_storage::init(&db_path).await?;
|
||||
eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin");
|
||||
let db_path = magnotia_storage::database_path();
|
||||
eprintln!(
|
||||
"[magnotia-mcp] opening Magnotia database at {} (read-only)",
|
||||
db_path.display()
|
||||
);
|
||||
// Open read-only at the connection level so the MCP server cannot write
|
||||
// to the user's database, regardless of which tools the dispatcher
|
||||
// exposes. Migrations are deliberately skipped — this binary never owns
|
||||
// the schema; the main app is the single migration writer.
|
||||
let pool = magnotia_storage::init_readonly(&db_path).await?;
|
||||
eprintln!("[magnotia-mcp] ready, waiting for JSON-RPC on stdin");
|
||||
|
||||
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||
let mut stdout = tokio::io::stdout();
|
||||
@@ -21,7 +28,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
let response = match serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
Ok(raw) => match kon_mcp::handle_message(&pool, raw).await {
|
||||
Ok(raw) => match magnotia_mcp::handle_message(&pool, raw).await {
|
||||
Some(response) => response,
|
||||
None => continue, // notification — no reply
|
||||
},
|
||||
@@ -31,8 +38,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
// logged and continued, dropping the response —
|
||||
// clients saw silence instead of a structured error
|
||||
// (2026-04-22 review MAJOR).
|
||||
eprintln!("[kon-mcp] parse error: {err}");
|
||||
kon_mcp::parse_error_response(&err.to_string())
|
||||
eprintln!("[magnotia-mcp] parse error: {err}");
|
||||
magnotia_mcp::parse_error_response(&err.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "kon-storage"
|
||||
name = "magnotia-storage"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "SQLite persistence, BM25 search, and file storage for Kon"
|
||||
description = "SQLite persistence, BM25 search, and file storage for Magnotia"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
magnotia-core = { path = "../core" }
|
||||
|
||||
# SQLite with compile-time checked queries
|
||||
# default-features = false strips sqlx's `any`, `macros`, `migrate`, `json` —
|
||||
@@ -18,6 +18,9 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio",
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["rt", "sync", "macros"] }
|
||||
|
||||
# Serialisation (DailyCompletionCount exposed to frontend via Tauri commands)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,79 +1,28 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolve the per-user app data directory, following each OS's convention:
|
||||
///
|
||||
/// - Windows: `%LOCALAPPDATA%\kon\` e.g. `C:\Users\Jake\AppData\Local\kon`
|
||||
/// - macOS: `~/Library/Application Support/Kon/`
|
||||
/// - Linux: `$XDG_DATA_HOME/kon` or `~/.local/share/kon` (XDG Base Directory),
|
||||
/// with a fallback to the legacy `~/.kon/` if it already exists, so
|
||||
/// existing installs keep working.
|
||||
/// - Other Unix: `~/.kon/`
|
||||
///
|
||||
/// TODO: Consolidate with `crates/transcription/src/model_manager.rs::dirs_path()`
|
||||
/// into a shared helper in `crates/core/` to avoid duplicating platform-specific
|
||||
/// path logic across crates.
|
||||
pub fn app_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
|
||||
return PathBuf::from(local_app_data).join("kon");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
return PathBuf::from(home)
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Kon");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
|
||||
// Honour the legacy ~/.kon/ if it exists on disk so existing
|
||||
// installs are not orphaned. New installs follow XDG.
|
||||
let legacy = PathBuf::from(&home).join(".kon");
|
||||
if legacy.exists() {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
// XDG Base Directory: $XDG_DATA_HOME/kon or default ~/.local/share/kon
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("kon");
|
||||
}
|
||||
}
|
||||
return PathBuf::from(home).join(".local").join("share").join("kon");
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".kon")
|
||||
}
|
||||
magnotia_core::paths::app_paths().app_data_dir()
|
||||
}
|
||||
|
||||
/// Path to the SQLite database file.
|
||||
pub fn database_path() -> PathBuf {
|
||||
app_data_dir().join("kon.db")
|
||||
magnotia_core::paths::app_paths().database_path()
|
||||
}
|
||||
|
||||
/// Directory for saved audio recordings.
|
||||
pub fn recordings_dir() -> PathBuf {
|
||||
app_data_dir().join("recordings")
|
||||
magnotia_core::paths::app_paths().recordings_dir()
|
||||
}
|
||||
|
||||
/// Directory for crash dumps written by the Rust panic hook.
|
||||
/// Each crash is a single text file: `<unix-ts>-<short-id>.crash`.
|
||||
/// Used by the diagnostic-report bundler in Settings → About.
|
||||
pub fn crashes_dir() -> PathBuf {
|
||||
app_data_dir().join("crashes")
|
||||
magnotia_core::paths::app_paths().crashes_dir()
|
||||
}
|
||||
|
||||
/// Directory for the rolling Rust log file (kon.log + rotated kon.log.1, etc).
|
||||
/// Directory for the rolling Rust log file (magnotia.log + rotated magnotia.log.1, etc).
|
||||
/// Subscribers configured in src-tauri/src/lib.rs at startup.
|
||||
pub fn logs_dir() -> PathBuf {
|
||||
app_data_dir().join("logs")
|
||||
magnotia_core::paths::app_paths().logs_dir()
|
||||
}
|
||||
|
||||
@@ -8,12 +8,17 @@ pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
pub use database::{
|
||||
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
|
||||
create_profile, delete_profile, delete_profile_term, delete_task, delete_transcript,
|
||||
get_profile, get_setting, get_task_by_id, get_transcript, init, insert_subtask, insert_task,
|
||||
insert_transcript, list_profile_terms, list_profiles, list_recent_errors, list_subtasks,
|
||||
list_tasks, list_transcripts, list_transcripts_paged, log_error, search_transcripts,
|
||||
set_setting, uncomplete_task, update_profile, update_task, update_transcript,
|
||||
update_transcript_meta, ErrorLogRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
||||
TaskRow, TranscriptRow,
|
||||
create_profile, delete_implementation_rule, delete_profile, delete_profile_term, delete_task,
|
||||
delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id,
|
||||
get_transcript, init, init_readonly, insert_implementation_rule, insert_subtask, insert_task,
|
||||
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
|
||||
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
|
||||
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
|
||||
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled,
|
||||
set_setting,
|
||||
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
|
||||
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
|
||||
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
||||
RecordFeedbackParams, TaskRow, TranscriptRow,
|
||||
};
|
||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use kon_core::error::{KonError, Result};
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Each migration is a (version, description, sql) tuple.
|
||||
@@ -334,6 +334,149 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
FROM transcripts;
|
||||
"#,
|
||||
),
|
||||
(
|
||||
10,
|
||||
"feedback: HITL thumbs + correction capture",
|
||||
r#"
|
||||
-- Feedback rows capture human-in-the-loop signal on AI-generated
|
||||
-- output. Two flavours bundled into one table:
|
||||
-- - thumbs (rating = -1 | +1, original_text optional, corrected_text NULL)
|
||||
-- - correction (rating defaults to +1, original_text + corrected_text present)
|
||||
--
|
||||
-- `target_type` names the producing surface:
|
||||
-- 'microstep' — subtask decomposition from DECOMPOSE_TASK_SYSTEM
|
||||
-- 'task_extraction' — tasks lifted from a transcript (EXTRACT_TASKS_SYSTEM)
|
||||
-- 'cleanup' — transcript cleanup output
|
||||
--
|
||||
-- `target_id` is the surface-specific identifier where one exists
|
||||
-- (subtask id, task id, transcript id). NULL is allowed because
|
||||
-- not every feedback event has a stable target id yet.
|
||||
--
|
||||
-- `context_json` carries the input the AI was conditioned on
|
||||
-- (parent task text, transcript chunk, etc.) so future prompt
|
||||
-- builders can reconstruct the original I/O pair for few-shot
|
||||
-- injection or semantic retrieval.
|
||||
CREATE TABLE feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_type TEXT NOT NULL
|
||||
CHECK (target_type IN ('microstep', 'task_extraction', 'cleanup')),
|
||||
target_id TEXT,
|
||||
rating INTEGER NOT NULL
|
||||
CHECK (rating IN (-1, 0, 1)),
|
||||
original_text TEXT,
|
||||
corrected_text TEXT,
|
||||
context_json TEXT,
|
||||
profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'
|
||||
REFERENCES profiles(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_feedback_target_type_rating
|
||||
ON feedback(target_type, rating, created_at DESC);
|
||||
CREATE INDEX idx_feedback_profile
|
||||
ON feedback(profile_id, target_type, created_at DESC);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
11,
|
||||
"tasks: energy tagging for match-my-energy sort",
|
||||
r#"
|
||||
-- Phase 3 of the feature-complete roadmap: replaces the cut
|
||||
-- temptation-bundling feature with a deterministic client-side
|
||||
-- sort that matches tasks to the user's current energy state.
|
||||
-- NULL is the expected normal case — users who never tag get
|
||||
-- Medium-equivalent treatment at sort time (see Match-my-energy
|
||||
-- logic in src/lib/pages/TasksPage.svelte).
|
||||
--
|
||||
-- profile_id is deliberately absent from the index: tasks
|
||||
-- currently carry no profile_id column, so a per-profile index
|
||||
-- is out of scope until the broader task → profile migration
|
||||
-- lands. See HANDOVER deferred list.
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN energy TEXT
|
||||
CHECK (energy IS NULL OR energy IN ('high', 'medium', 'brain_dead'));
|
||||
|
||||
CREATE INDEX idx_tasks_energy_created
|
||||
ON tasks(energy, created_at DESC);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
12,
|
||||
"implementation intentions: if-then automation rules",
|
||||
r#"
|
||||
-- Phase 7 of the feature-complete roadmap. Rules are local-only,
|
||||
-- user-authored implementation intentions: "if this happens, then
|
||||
-- do this small thing". Execution stays in the frontend event bus;
|
||||
-- SQLite owns the durable definition and the once-per-day marker
|
||||
-- for time-of-day rules.
|
||||
CREATE TABLE implementation_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (enabled IN (0, 1)),
|
||||
trigger_kind TEXT NOT NULL
|
||||
CHECK (trigger_kind IN (
|
||||
'time_of_day',
|
||||
'task_completed',
|
||||
'morning_triage_finished'
|
||||
)),
|
||||
trigger_value TEXT NOT NULL DEFAULT '',
|
||||
actions_json TEXT NOT NULL DEFAULT '[]',
|
||||
last_fired_key TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_implementation_rules_enabled_trigger
|
||||
ON implementation_rules(enabled, trigger_kind);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
13,
|
||||
"gamification: auto_completed flag for cascade-completed parents",
|
||||
r#"
|
||||
-- Phase 8 of the feature-complete roadmap. Parents that close via
|
||||
-- the complete_subtask_and_check_parent cascade must not count
|
||||
-- towards daily completion totals. The user already got credit
|
||||
-- for ticking the subtask. This column distinguishes manual
|
||||
-- completions (0) from cascade completions (1). The daily-count
|
||||
-- query then excludes auto_completed = 1.
|
||||
--
|
||||
-- Partial index keeps the index small: only completed rows occupy
|
||||
-- it, since uncompleted rows have done_at IS NULL.
|
||||
ALTER TABLE tasks ADD COLUMN auto_completed INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (auto_completed IN (0, 1));
|
||||
|
||||
CREATE INDEX idx_tasks_done_at_auto_completed
|
||||
ON tasks(done_at, auto_completed)
|
||||
WHERE done_at IS NOT NULL;
|
||||
"#,
|
||||
),
|
||||
(
|
||||
14,
|
||||
"transcripts: llm_tags column for Phase 9 LLM content tags",
|
||||
r#"
|
||||
-- Phase 9 of the feature-complete roadmap. AI-generated content
|
||||
-- tags (topic:* and intent:*) are stored alongside manual_tags as
|
||||
-- a comma-joined string, mirroring how manual_tags persists. Pre-
|
||||
-- existing rows default to empty string. The frontend chips loop
|
||||
-- handles "" as "no tags".
|
||||
ALTER TABLE transcripts ADD COLUMN llm_tags TEXT NOT NULL DEFAULT '';
|
||||
"#,
|
||||
),
|
||||
(
|
||||
15,
|
||||
"transcripts: composite (profile_id, created_at DESC) index",
|
||||
r#"
|
||||
-- Performance index for the dominant transcripts query path:
|
||||
-- WHERE profile_id = ? ORDER BY created_at DESC LIMIT ?
|
||||
-- The standalone idx_transcripts_profile_id and idx_transcripts_created
|
||||
-- forced SQLite to either filter by profile then sort, or scan the date
|
||||
-- index and filter — fine at hundreds of rows, painful past a few thousand.
|
||||
-- A composite index covers both predicates in one ordered seek.
|
||||
CREATE INDEX IF NOT EXISTS idx_transcripts_profile_created
|
||||
ON transcripts(profile_id, created_at DESC);
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -410,19 +553,19 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Schema version table creation failed: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Schema version table creation failed: {e}")))?;
|
||||
|
||||
let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Schema version query failed: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Schema version query failed: {e}")))?;
|
||||
|
||||
for (version, description, sql) in migrations {
|
||||
if *version > current {
|
||||
log::info!("Running migration {}: {}", version, description);
|
||||
|
||||
let mut tx = pool.begin().await.map_err(|e| {
|
||||
KonError::StorageError(format!("Migration {} tx begin failed: {e}", version))
|
||||
MagnotiaError::StorageError(format!("Migration {} tx begin failed: {e}", version))
|
||||
})?;
|
||||
|
||||
for statement in split_statements(sql) {
|
||||
@@ -430,7 +573,7 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
KonError::StorageError(format!("Migration {} failed: {e}", version))
|
||||
MagnotiaError::StorageError(format!("Migration {} failed: {e}", version))
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -440,11 +583,11 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
KonError::StorageError(format!("Migration version record failed: {e}"))
|
||||
MagnotiaError::StorageError(format!("Migration version record failed: {e}"))
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
KonError::StorageError(format!("Migration {} commit failed: {e}", version))
|
||||
MagnotiaError::StorageError(format!("Migration {} commit failed: {e}", version))
|
||||
})?;
|
||||
|
||||
log::info!("Migration {} complete", version);
|
||||
@@ -483,7 +626,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 9);
|
||||
assert_eq!(count, 15);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -502,7 +645,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 9);
|
||||
assert_eq!(count, 15);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -530,6 +673,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_implementation_rules_adds_rule_table() {
|
||||
let pool = fk_test_pool().await;
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
let info = sqlx::query("PRAGMA table_info(implementation_rules)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||
for col in [
|
||||
"id",
|
||||
"enabled",
|
||||
"trigger_kind",
|
||||
"trigger_value",
|
||||
"actions_json",
|
||||
"last_fired_key",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&col.to_string()),
|
||||
"implementation_rules must have {col}; got {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let rejected = sqlx::query(
|
||||
"INSERT INTO implementation_rules (id, trigger_kind, actions_json)
|
||||
VALUES ('bad', 'calendar_event', '[]')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
assert!(
|
||||
rejected.is_err(),
|
||||
"trigger_kind CHECK constraint must reject unknown triggers"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_transcripts_meta_adds_columns() {
|
||||
// Task 2.5 — verify starred / manual_tags / template / language /
|
||||
@@ -766,7 +947,7 @@ mod tests {
|
||||
// dictionary.id is INTEGER PK AUTOINCREMENT (see v2); let SQLite assign rowids.
|
||||
sqlx::query(
|
||||
"INSERT INTO dictionary (term, note, created_at) VALUES \
|
||||
('Kon', '', datetime('now')), \
|
||||
('Magnotia', '', datetime('now')), \
|
||||
('CORBEL', 'brand', datetime('now')), \
|
||||
('Wren', '', datetime('now'))",
|
||||
)
|
||||
@@ -859,8 +1040,11 @@ mod tests {
|
||||
// The poisoned migration below first creates `poison_marker`
|
||||
// (syntactically valid, would succeed against any SQLite) and then
|
||||
// runs a guaranteed-invalid function call. Under the new atomic
|
||||
// implementation, neither `poison_marker` nor the v9 row should
|
||||
// implementation, neither `poison_marker` nor the poison row should
|
||||
// survive the failed call.
|
||||
//
|
||||
// Version number must sit above the real MIGRATIONS max so the
|
||||
// baseline migrate cleanly finishes first.
|
||||
#[tokio::test]
|
||||
async fn multi_statement_migration_rolls_back_on_failure() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
@@ -871,8 +1055,18 @@ mod tests {
|
||||
|
||||
run_migrations(&pool).await.expect("baseline migrate");
|
||||
|
||||
const POISON: &[(i64, &str, &str)] = &[(
|
||||
10,
|
||||
// Discover the real max version so the poison migration is
|
||||
// always exactly one past the end of MIGRATIONS, regardless of
|
||||
// how many real migrations we add in future.
|
||||
let real_max: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read schema_version");
|
||||
let poison_version = real_max + 1;
|
||||
|
||||
let poison: &[(i64, &str, &str)] = &[(
|
||||
poison_version,
|
||||
"rb-02 atomicity poison",
|
||||
r#"
|
||||
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
|
||||
@@ -880,7 +1074,7 @@ mod tests {
|
||||
"#,
|
||||
)];
|
||||
|
||||
let result = run_migrations_slice(&pool, POISON).await;
|
||||
let result = run_migrations_slice(&pool, poison).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"poisoned migration must return Err, got: {result:?}"
|
||||
@@ -896,15 +1090,96 @@ mod tests {
|
||||
"poison_marker must not exist; got: {marker:?}"
|
||||
);
|
||||
|
||||
// `schema_version` must not include v10 — version insert is part
|
||||
// of the same transaction that rolled back.
|
||||
// `schema_version` must not include the poison version — version
|
||||
// insert is part of the same transaction that rolled back.
|
||||
let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read schema_version");
|
||||
assert_eq!(
|
||||
max, 9,
|
||||
max, real_max,
|
||||
"schema_version must not advance past the failed migration"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_v13_adds_auto_completed_column() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("pool");
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
// Column exists.
|
||||
let info = sqlx::query("PRAGMA table_info(tasks)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("pragma");
|
||||
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||
assert!(
|
||||
names.iter().any(|n| n == "auto_completed"),
|
||||
"expected auto_completed column, got {names:?}"
|
||||
);
|
||||
|
||||
// Existing completed rows default to 0. Insert a pre-existing-looking
|
||||
// task via raw SQL to simulate a row from before the migration.
|
||||
sqlx::query(
|
||||
"INSERT INTO tasks (id, text, bucket, done, done_at) \
|
||||
VALUES ('t1', 'pre-existing', 'inbox', 1, '2026-04-20 12:00:00')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 't1'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("query");
|
||||
assert_eq!(auto, 0, "pre-existing completed rows must default to 0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_v15_creates_profile_created_index() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("pool");
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
// Index exists by name.
|
||||
let names: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT name FROM sqlite_master \
|
||||
WHERE type = 'index' AND tbl_name = 'transcripts'",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("read indexes");
|
||||
assert!(
|
||||
names.iter().any(|n| n == "idx_transcripts_profile_created"),
|
||||
"expected composite (profile_id, created_at) index, got {names:?}",
|
||||
);
|
||||
|
||||
// Query planner picks the composite index for the dominant
|
||||
// profile-scoped, date-ordered list query. EXPLAIN QUERY PLAN
|
||||
// returns (id, parent, notused, detail) — we want detail.
|
||||
let plan_rows = sqlx::query(
|
||||
"EXPLAIN QUERY PLAN \
|
||||
SELECT id FROM transcripts \
|
||||
WHERE profile_id = ? ORDER BY created_at DESC LIMIT 50",
|
||||
)
|
||||
.bind(crate::DEFAULT_PROFILE_ID)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("explain");
|
||||
let plan: Vec<String> = plan_rows
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("detail"))
|
||||
.collect();
|
||||
assert!(
|
||||
plan.iter().any(|row| row.contains("idx_transcripts_profile_created")),
|
||||
"planner should use the composite index, got plan: {plan:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
[package]
|
||||
name = "kon-transcription"
|
||||
name = "magnotia-transcription"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Speech-to-text engine wrappers, model management, and inference concurrency for Kon"
|
||||
description = "Speech-to-text engine wrappers, model management, and inference concurrency for Magnotia"
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
# Whisper backend (direct whisper-rs, vulkan-accelerated). Default on —
|
||||
# gating it exists so a future Windows non-AVX2 build, or a cloud-only
|
||||
# ASR configuration, can drop whisper-rs-sys entirely per brief item
|
||||
# #13. Disabling this feature also drops the WhisperRsBackend module
|
||||
# and the load_whisper entry point.
|
||||
default = ["whisper"]
|
||||
whisper = ["dep:whisper-rs", "dep:num_cpus"]
|
||||
# Whisper backend (direct whisper-rs). Default on — gating it exists so
|
||||
# a future Windows non-AVX2 build, or a cloud-only ASR configuration,
|
||||
# can drop whisper-rs-sys entirely per brief item #13. Disabling this
|
||||
# feature also drops the WhisperRsBackend module and the load_whisper
|
||||
# entry point.
|
||||
#
|
||||
# `whisper-vulkan` is a separate feature so a non-Vulkan target (Android
|
||||
# without GPU drivers, a CPU-only Windows build) can pull in whisper-rs
|
||||
# but skip the Vulkan backend. Build CPU-only with:
|
||||
# cargo build -p magnotia-transcription --no-default-features --features whisper
|
||||
default = ["whisper", "whisper-vulkan"]
|
||||
whisper = ["dep:whisper-rs"]
|
||||
whisper-vulkan = ["whisper-rs?/vulkan"]
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
magnotia-core = { path = "../core" }
|
||||
|
||||
# Parakeet via ONNX. Whisper is handled directly via whisper-rs below.
|
||||
transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"] }
|
||||
@@ -24,18 +30,15 @@ transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"]
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
|
||||
# Model downloads
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Download integrity verification
|
||||
sha2 = "0.10"
|
||||
|
||||
# Gated behind the `whisper` feature (see [features] above).
|
||||
whisper-rs = { version = "0.16", default-features = false, features = ["vulkan"], optional = true }
|
||||
|
||||
# Direct whisper-rs backend (WhisperRsBackend): thread pool sizing.
|
||||
# Gated alongside whisper-rs since no other code in this crate needs it.
|
||||
num_cpus = { version = "1", optional = true }
|
||||
# Gated behind the `whisper` feature (see [features] above). Vulkan is
|
||||
# additive via the `whisper-vulkan` feature so non-GPU targets can drop it.
|
||||
whisper-rs = { version = "0.16", default-features = false, optional = true }
|
||||
|
||||
# Typed error enum used by WhisperRsBackend + elsewhere. Kept
|
||||
# unconditional because it is a derive-macro crate with negligible
|
||||
@@ -46,6 +49,10 @@ thiserror = "2"
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
# TcpListener fixture for the download resume tests (mirrors kon-llm).
|
||||
# TcpListener fixture for the download resume tests (mirrors magnotia-llm).
|
||||
tokio = { version = "1", features = ["rt", "sync", "net", "io-util", "macros"] }
|
||||
tempfile = "3"
|
||||
# Test-only — used by tests/thread_sweep.rs to label physical vs logical
|
||||
# core counts in the scaling table. Production code uses the
|
||||
# `magnotia_core::constants::inference_thread_count` helper instead.
|
||||
num_cpus = "1"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! workspace ever pulls `tokenizers` into the dependency graph on a
|
||||
//! Windows target. If we ever legitimately need it we can reintroduce
|
||||
//! it via a sidecar (isolated process, separate CRT) rather than
|
||||
//! linking it into `kon_lib`.
|
||||
//! linking it into `magnotia_lib`.
|
||||
//!
|
||||
//! The check is advisory on non-Windows targets — it still prints a
|
||||
//! cargo:warning if `tokenizers` appears, so the Windows failure isn't
|
||||
@@ -56,7 +56,7 @@ fn main() {
|
||||
|
||||
if target_os == "windows" {
|
||||
panic!(
|
||||
"kon-transcription: the `tokenizers` crate appears in Cargo.lock and this is a \
|
||||
"magnotia-transcription: the `tokenizers` crate appears in Cargo.lock and this is a \
|
||||
Windows build. Linking `whisper-rs-sys` + `tokenizers` in the same binary has \
|
||||
been a persistent MSVC C-runtime conflict (see Whispering v7.11.0). Route any \
|
||||
tokenizer usage through an out-of-process sidecar instead, or gate it off for \
|
||||
@@ -65,7 +65,7 @@ fn main() {
|
||||
}
|
||||
|
||||
println!(
|
||||
"cargo:warning=kon-transcription: `tokenizers` crate is in the dependency graph. \
|
||||
"cargo:warning=magnotia-transcription: `tokenizers` crate is in the dependency graph. \
|
||||
This build is non-Windows so the link will succeed, but Windows builds will panic \
|
||||
at build time per docs/whisper-ecosystem/brief.md item #6. Isolate tokenizer usage \
|
||||
in a sidecar before a Windows ship."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::{AudioSamples, TranscriptionOptions};
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use magnotia_core::types::{AudioSamples, TranscriptionOptions};
|
||||
|
||||
use crate::local_engine::{LocalEngine, TimedTranscript};
|
||||
|
||||
@@ -14,5 +14,5 @@ pub async fn run_inference(
|
||||
) -> Result<TimedTranscript> {
|
||||
tokio::task::spawn_blocking(move || engine.transcribe_sync(&audio, &options))
|
||||
.await
|
||||
.map_err(|e| KonError::TranscriptionFailed(format!("Task join error: {e}")))?
|
||||
.map_err(|e| MagnotiaError::TranscriptionFailed(format!("Task join error: {e}")))?
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::time::Instant;
|
||||
|
||||
use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::{
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use magnotia_core::types::{
|
||||
AudioSamples, EngineName, ModelId, Segment, Transcript, TranscriptionOptions,
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ pub struct SpeechModelAdapter(pub Box<dyn SpeechModel + Send>);
|
||||
impl Transcriber for SpeechModelAdapter {
|
||||
fn capabilities(&self) -> TranscriberCapabilities {
|
||||
TranscriberCapabilities {
|
||||
sample_rate: kon_core::constants::WHISPER_SAMPLE_RATE,
|
||||
sample_rate: magnotia_core::constants::WHISPER_SAMPLE_RATE,
|
||||
channels: 1,
|
||||
supports_initial_prompt: false,
|
||||
}
|
||||
@@ -48,7 +48,7 @@ impl Transcriber for SpeechModelAdapter {
|
||||
let result: TranscriptionResult = self
|
||||
.0
|
||||
.transcribe(samples, &opts)
|
||||
.map_err(|e| KonError::TranscriptionFailed(e.to_string()))?;
|
||||
.map_err(|e| MagnotiaError::TranscriptionFailed(e.to_string()))?;
|
||||
Ok(result
|
||||
.segments
|
||||
.unwrap_or_default()
|
||||
@@ -140,7 +140,7 @@ impl LocalEngine {
|
||||
options: &TranscriptionOptions,
|
||||
) -> Result<TimedTranscript> {
|
||||
let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let backend = guard.as_mut().ok_or(KonError::EngineNotLoaded)?;
|
||||
let backend = guard.as_mut().ok_or(MagnotiaError::EngineNotLoaded)?;
|
||||
|
||||
let start = Instant::now();
|
||||
let segments = backend.transcribe_sync(audio.samples(), options)?;
|
||||
@@ -160,7 +160,7 @@ impl LocalEngine {
|
||||
/// Thin wrapper over `ParakeetModel` that overrides `transcribe_raw` to
|
||||
/// request word-granularity segments. `transcribe-rs` 0.3's trait impl for
|
||||
/// `ParakeetModel::transcribe_raw` ignores `TranscribeOptions` and uses
|
||||
/// `TimestampGranularity::Token` (per-subword) — which surfaces in Kon as
|
||||
/// `TimestampGranularity::Token` (per-subword) — which surfaces in Magnotia as
|
||||
/// "T Est Ing . One , Two , Three" output. The concrete-type method
|
||||
/// `ParakeetModel::transcribe_with` accepts `ParakeetParams` with an
|
||||
/// explicit granularity; this wrapper exposes that to the trait object.
|
||||
@@ -197,7 +197,7 @@ impl transcribe_rs::SpeechModel for ParakeetWordGranularity {
|
||||
pub fn load_parakeet(model_dir: &Path) -> Result<Box<dyn Transcriber + Send>> {
|
||||
use transcribe_rs::onnx::Quantization;
|
||||
let model = transcribe_rs::onnx::parakeet::ParakeetModel::load(model_dir, &Quantization::Int8)
|
||||
.map_err(|e| KonError::TranscriptionFailed(format!("Failed to load Parakeet: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::TranscriptionFailed(format!("Failed to load Parakeet: {e}")))?;
|
||||
Ok(Box::new(SpeechModelAdapter(Box::new(
|
||||
ParakeetWordGranularity(model),
|
||||
))))
|
||||
@@ -207,7 +207,7 @@ pub fn load_parakeet(model_dir: &Path) -> Result<Box<dyn Transcriber + Send>> {
|
||||
#[cfg(feature = "whisper")]
|
||||
pub fn load_whisper(model_path: &Path) -> Result<Box<dyn Transcriber + Send>> {
|
||||
let backend = WhisperRsBackend::load(model_path)
|
||||
.map_err(|e| KonError::TranscriptionFailed(format!("Failed to load Whisper: {e}")))?;
|
||||
.map_err(|e| MagnotiaError::TranscriptionFailed(format!("Failed to load Whisper: {e}")))?;
|
||||
Ok(Box::new(backend))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,51 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::model_registry::{find_model, ModelFile};
|
||||
use kon_core::types::{DownloadProgress, ModelId};
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use magnotia_core::model_registry::{find_model, ModelFile};
|
||||
use magnotia_core::types::{DownloadProgress, ModelId};
|
||||
|
||||
/// Resolve the models storage directory.
|
||||
/// Windows: %LOCALAPPDATA%/kon/models
|
||||
/// Unix: ~/.kon/models
|
||||
pub fn models_dir() -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(local_app_data).join("kon").join("models")
|
||||
} else {
|
||||
dirs_path().join("models")
|
||||
static ACTIVE_DOWNLOADS: LazyLock<Mutex<HashSet<String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
|
||||
struct DownloadReservation {
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl DownloadReservation {
|
||||
fn acquire(id: &ModelId) -> Result<Self> {
|
||||
let id = id.as_str().to_string();
|
||||
let mut active = ACTIVE_DOWNLOADS
|
||||
.lock()
|
||||
.map_err(|_| MagnotiaError::DownloadFailed("download lock poisoned".into()))?;
|
||||
if !active.insert(id.clone()) {
|
||||
return Err(MagnotiaError::DownloadFailed(format!(
|
||||
"download already in progress for {id}"
|
||||
)));
|
||||
}
|
||||
Ok(Self { id })
|
||||
}
|
||||
}
|
||||
|
||||
fn dirs_path() -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(local_app_data).join("kon")
|
||||
} else {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".kon")
|
||||
impl Drop for DownloadReservation {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut active) = ACTIVE_DOWNLOADS.lock() {
|
||||
active.remove(&self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the models storage directory.
|
||||
/// Windows: %LOCALAPPDATA%/magnotia/models
|
||||
/// Unix: ~/.magnotia/models
|
||||
pub fn models_dir() -> PathBuf {
|
||||
magnotia_core::paths::app_paths().models_dir()
|
||||
}
|
||||
|
||||
/// Get the directory path where a specific model's files are stored.
|
||||
pub fn model_dir(id: &ModelId) -> PathBuf {
|
||||
models_dir().join(id.as_str())
|
||||
magnotia_core::paths::app_paths().speech_model_dir(id)
|
||||
}
|
||||
|
||||
/// Check whether all files for a model have been downloaded.
|
||||
@@ -39,11 +56,12 @@ pub fn is_downloaded(id: &ModelId) -> bool {
|
||||
};
|
||||
let dir = model_dir(id);
|
||||
entry.files.iter().all(|f| dir.join(f.filename).exists())
|
||||
&& verified_manifest_matches(entry, &dir)
|
||||
}
|
||||
|
||||
/// List all downloaded model IDs.
|
||||
pub fn list_downloaded() -> Vec<ModelId> {
|
||||
kon_core::model_registry::all_models()
|
||||
magnotia_core::model_registry::all_models()
|
||||
.iter()
|
||||
.filter(|m| is_downloaded(&m.id))
|
||||
.map(|m| m.id.clone())
|
||||
@@ -56,12 +74,13 @@ pub fn list_downloaded() -> Vec<ModelId> {
|
||||
/// For files that declare a `sha256` checksum we validate an existing
|
||||
/// complete file before skipping the download — a truncated or
|
||||
/// tampered file gets redownloaded automatically (pattern ported from
|
||||
/// `kon-llm`'s model_manager, item #8 in the Whisper ecosystem brief).
|
||||
/// `magnotia-llm`'s model_manager, item #8 in the Whisper ecosystem brief).
|
||||
pub async fn download(
|
||||
id: &ModelId,
|
||||
progress: impl Fn(DownloadProgress) + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let entry = find_model(id).ok_or_else(|| KonError::ModelNotFound(id.clone()))?;
|
||||
let _reservation = DownloadReservation::acquire(id)?;
|
||||
let entry = find_model(id).ok_or_else(|| MagnotiaError::ModelNotFound(id.clone()))?;
|
||||
|
||||
let dir = model_dir(id);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
@@ -69,36 +88,70 @@ pub async fn download(
|
||||
for file in &entry.files {
|
||||
let dest = dir.join(file.filename);
|
||||
if dest.exists() {
|
||||
if let Some(expected_sha) = file.sha256 {
|
||||
// Validate the existing file. If the hash doesn't match,
|
||||
// the file is corrupt (partial download, tampering, bit
|
||||
// rot) and we must re-fetch it to avoid crashing on
|
||||
// model load later.
|
||||
match sha256_of_file(&dest) {
|
||||
Ok(actual) if actual.eq_ignore_ascii_case(expected_sha) => continue,
|
||||
Ok(_actual) => {
|
||||
// Corrupt — remove + fall through to re-download.
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"failed to verify existing {}: {e}",
|
||||
file.filename
|
||||
)));
|
||||
}
|
||||
// Validate the existing file. If the hash doesn't match,
|
||||
// the file is corrupt (partial download, tampering, bit
|
||||
// rot) and we must re-fetch it to avoid crashing on
|
||||
// model load later.
|
||||
match sha256_of_file(&dest) {
|
||||
Ok(actual) if actual.eq_ignore_ascii_case(file.sha256) => continue,
|
||||
Ok(_actual) => {
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(MagnotiaError::DownloadFailed(format!(
|
||||
"failed to verify existing {}: {e}",
|
||||
file.filename
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
// No checksum — honour the existing file as-is; the
|
||||
// engine will barf on load if it's broken.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
download_file(file, &dest, id, &progress).await?;
|
||||
}
|
||||
|
||||
write_verified_manifest(entry, &dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verified_manifest_path(dir: &Path) -> PathBuf {
|
||||
dir.join(".magnotia-verified")
|
||||
}
|
||||
|
||||
fn verified_manifest_matches(entry: &magnotia_core::model_registry::ModelEntry, dir: &Path) -> bool {
|
||||
let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) {
|
||||
Ok(contents) => contents,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
for file in &entry.files {
|
||||
let path = dir.join(file.filename);
|
||||
let size = match std::fs::metadata(&path) {
|
||||
Ok(metadata) => metadata.len(),
|
||||
Err(_) => return false,
|
||||
};
|
||||
let expected_line = format!("{}\t{}\t{}", file.filename, file.sha256, size);
|
||||
if !manifest.lines().any(|line| line == expected_line) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn write_verified_manifest(
|
||||
entry: &magnotia_core::model_registry::ModelEntry,
|
||||
dir: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
let mut lines = Vec::with_capacity(entry.files.len() + 1);
|
||||
lines.push("version\t1".to_string());
|
||||
for file in &entry.files {
|
||||
let size = std::fs::metadata(dir.join(file.filename))?.len();
|
||||
lines.push(format!("{}\t{}\t{}", file.filename, file.sha256, size));
|
||||
}
|
||||
std::fs::write(
|
||||
verified_manifest_path(dir),
|
||||
format!("{}\n", lines.join("\n")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Non-streaming SHA256 of a file on disk. Used by `download()` to
|
||||
/// validate an existing complete file before trusting it.
|
||||
fn sha256_of_file(path: &Path) -> std::io::Result<String> {
|
||||
@@ -140,7 +193,7 @@ async fn download_file(
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
.map_err(|e| MagnotiaError::DownloadFailed(e.to_string()))?;
|
||||
|
||||
// Check for existing partial download (resume support)
|
||||
let existing_bytes = if part_path.exists() {
|
||||
@@ -151,9 +204,7 @@ async fn download_file(
|
||||
|
||||
let mut request = client.get(file.url);
|
||||
|
||||
// If we have a partial file and no SHA256 to verify (can't verify partial),
|
||||
// request a range resume. If SHA256 is set, we restart to ensure integrity.
|
||||
let resuming = existing_bytes > 0 && file.sha256.is_none();
|
||||
let resuming = existing_bytes > 0;
|
||||
if resuming {
|
||||
request = request.header("Range", format!("bytes={existing_bytes}-"));
|
||||
}
|
||||
@@ -161,12 +212,12 @@ async fn download_file(
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
.map_err(|e| MagnotiaError::DownloadFailed(e.to_string()))?;
|
||||
|
||||
// If we requested Range but the server returned 200 (full file), the
|
||||
// server does not support resume. Rather than blindly appending a
|
||||
// full file on top of our partial bytes (which would produce a
|
||||
// corrupt result), restart cleanly. This mirrors the kon-llm
|
||||
// corrupt result), restart cleanly. This mirrors the magnotia-llm
|
||||
// ResumeUnsupported branch — item #8 of the brief.
|
||||
//
|
||||
// For the non-resume path, we still have to validate the status:
|
||||
@@ -183,14 +234,14 @@ async fn download_file(
|
||||
false
|
||||
}
|
||||
other => {
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
return Err(MagnotiaError::DownloadFailed(format!(
|
||||
"resume request returned unexpected status {other}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !response.status().is_success() {
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
return Err(MagnotiaError::DownloadFailed(format!(
|
||||
"download returned HTTP {} for {}",
|
||||
response.status(),
|
||||
file.filename
|
||||
@@ -223,19 +274,23 @@ async fn download_file(
|
||||
std::fs::File::create(&part_path)?
|
||||
};
|
||||
|
||||
// Incremental SHA256 — only when a checksum is provided
|
||||
let mut hasher = file.sha256.map(|_| Sha256::new());
|
||||
|
||||
// If resuming without SHA256, we can't hash the already-downloaded portion,
|
||||
// but we also don't need to — we only hash when sha256 is set, and we
|
||||
// restart from scratch in that case.
|
||||
let mut hasher = Sha256::new();
|
||||
if actually_resuming {
|
||||
let mut partial = std::fs::File::open(&part_path)?;
|
||||
let mut buffer = [0u8; 8192];
|
||||
loop {
|
||||
let n = std::io::Read::read(&mut partial, &mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
let chunk = chunk.map_err(|e| MagnotiaError::DownloadFailed(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut out, &chunk)?;
|
||||
if let Some(ref mut h) = hasher {
|
||||
h.update(&chunk);
|
||||
}
|
||||
hasher.update(&chunk);
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
let percent = if total_bytes > 0 {
|
||||
@@ -258,17 +313,13 @@ async fn download_file(
|
||||
|
||||
drop(out);
|
||||
|
||||
// Verify SHA256 if provided
|
||||
if let (Some(expected), Some(hasher)) = (file.sha256, hasher) {
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != expected {
|
||||
// Delete corrupt file so next attempt starts fresh
|
||||
let _ = std::fs::remove_file(&part_path);
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
file.filename, expected, actual
|
||||
)));
|
||||
}
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != file.sha256 {
|
||||
let _ = std::fs::remove_file(&part_path);
|
||||
return Err(MagnotiaError::DownloadFailed(format!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
file.filename, file.sha256, actual
|
||||
)));
|
||||
}
|
||||
|
||||
// Atomic rename — file is complete and verified
|
||||
@@ -303,7 +354,7 @@ mod tests {
|
||||
let list = list_downloaded();
|
||||
// In test environment, no models are downloaded
|
||||
// This just verifies the function doesn't panic
|
||||
assert!(list.len() <= kon_core::model_registry::all_models().len());
|
||||
assert!(list.len() <= magnotia_core::model_registry::all_models().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -427,8 +478,8 @@ mod tests {
|
||||
let file = ModelFile {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: None, // resume path only kicks in when sha256 is absent
|
||||
size: magnotia_core::types::Megabytes(0),
|
||||
sha256: leak(expected_sha.clone()),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
@@ -437,9 +488,6 @@ mod tests {
|
||||
let bytes = std::fs::read(&dest).unwrap();
|
||||
assert_eq!(bytes, body);
|
||||
assert!(!part.exists());
|
||||
// Confirm the full file hash matches what we would have got via
|
||||
// a clean download — gives the resume path indirect integrity
|
||||
// coverage even when the ModelFile has no sha256 set.
|
||||
assert_eq!(sha256_of_file(&dest).unwrap(), expected_sha);
|
||||
}
|
||||
|
||||
@@ -451,6 +499,7 @@ mod tests {
|
||||
// partial bytes and write the fresh body from offset zero rather
|
||||
// than appending on top.
|
||||
let body = b"fresh transcription payload that replaces any stale partial".to_vec();
|
||||
let expected_sha = format!("{:x}", sha2::Sha256::digest(&body));
|
||||
let addr = spawn_no_range_server(body.clone()).await;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -464,8 +513,8 @@ mod tests {
|
||||
let file = ModelFile {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: None,
|
||||
size: magnotia_core::types::Megabytes(0),
|
||||
sha256: leak(expected_sha),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
@@ -519,8 +568,8 @@ mod tests {
|
||||
let file = ModelFile {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: None,
|
||||
size: magnotia_core::types::Megabytes(0),
|
||||
sha256: leak("0".repeat(64)),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
@@ -547,8 +596,8 @@ mod tests {
|
||||
let file = ModelFile {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: Some(leak("deadbeef".repeat(8))),
|
||||
size: magnotia_core::types::Megabytes(0),
|
||||
sha256: leak("deadbeef".repeat(8)),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ mod tests {
|
||||
let mut total_pushed: u64 = 0;
|
||||
let tentative_per_cycle: u64 = 200;
|
||||
for _ in 0..100 {
|
||||
buf.extend(std::iter::repeat(0.25_f32).take(16_000));
|
||||
buf.extend(std::iter::repeat_n(0.25_f32, 16_000));
|
||||
total_pushed += 16_000;
|
||||
let commit_point = total_pushed - tentative_per_cycle;
|
||||
start = trim_buffer_to_commit_point(&mut buf, start, commit_point);
|
||||
@@ -199,7 +199,7 @@ mod tests {
|
||||
|
||||
// Simulate a capture buffer that has received 1.2 s of audio
|
||||
// starting at t=0.
|
||||
let mut buf: Vec<f32> = std::iter::repeat(0.1_f32).take(19_200).collect();
|
||||
let mut buf: Vec<f32> = std::iter::repeat_n(0.1_f32, 19_200).collect();
|
||||
let new_start = trim_buffer_to_commit_point(&mut buf, 0, commit_idx);
|
||||
assert_eq!(new_start, 8_000);
|
||||
assert_eq!(buf.len(), 19_200 - 8_000);
|
||||
|
||||
@@ -306,7 +306,7 @@ impl VadChunker for RmsVadChunker {
|
||||
.saturating_sub(self.pending.len() as u64);
|
||||
let pad_len = FRAME_SAMPLES - self.pending.len();
|
||||
let mut padded = std::mem::take(&mut self.pending);
|
||||
padded.extend(std::iter::repeat(0.0_f32).take(pad_len));
|
||||
padded.extend(std::iter::repeat_n(0.0_f32, pad_len));
|
||||
if let Some(chunk) = self.consume_frame(padded, frame_start) {
|
||||
emitted.push(chunk);
|
||||
}
|
||||
@@ -318,17 +318,25 @@ impl VadChunker for RmsVadChunker {
|
||||
// whatever is still open as the closing chunk.
|
||||
if self.state == State::InSpeech && !self.active_chunk.is_empty() {
|
||||
emitted.push(self.emit_active_chunk_and_close());
|
||||
} else if self.state == State::InSpeech {
|
||||
// hit_max emitted mid-flush and left state in InSpeech
|
||||
// with active_chunk empty. Reset cleanly without emitting
|
||||
// a zero-length closing chunk — the hit_max chunk already
|
||||
// carried all the audio.
|
||||
self.state = State::Idle;
|
||||
self.silent_tail_samples = 0;
|
||||
self.pending_onset_frames = 0;
|
||||
self.onset_buffer.clear();
|
||||
}
|
||||
|
||||
// Defence in depth: every flush exit-path must leave the chunker
|
||||
// in the same clean state a freshly-constructed one is in,
|
||||
// bar `next_sample_index` (the running total-samples counter,
|
||||
// intentionally preserved across flush). Without this, a flush
|
||||
// that emitted via `consume_frame`'s hit_max branch could leave
|
||||
// `state == InSpeech` with stale `silent_tail_samples` or a
|
||||
// populated `onset_buffer`, so the next feed() bleeds prior-
|
||||
// session state into the first chunk of a fresh recording.
|
||||
// The earlier branches already did most of this; the explicit
|
||||
// clear here is a single source of truth.
|
||||
self.state = State::Idle;
|
||||
self.pending.clear();
|
||||
self.active_chunk.clear();
|
||||
self.silent_tail_samples = 0;
|
||||
self.pending_onset_frames = 0;
|
||||
self.onset_buffer.clear();
|
||||
|
||||
emitted
|
||||
}
|
||||
|
||||
@@ -683,4 +691,45 @@ mod tests {
|
||||
"start_sample must not skip past the onset frames"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_is_idempotent_and_leaves_clean_state() {
|
||||
// Drive the chunker through a full speech-then-silence cycle so
|
||||
// most of the state-machine fields are exercised, flush once,
|
||||
// then assert that flushing again is a no-op AND that feed-with-
|
||||
// silence emits nothing (i.e. no stale onset / silent_tail
|
||||
// bookkeeping leaks into the next feed).
|
||||
let mut c = RmsVadChunker::with_thresholds(
|
||||
0.01,
|
||||
0.005,
|
||||
DEFAULT_SPEECH_ONSET_FRAMES,
|
||||
FRAME_SAMPLES * 4,
|
||||
FRAME_SAMPLES * 50,
|
||||
);
|
||||
|
||||
let speech = constant_signal(FRAME_SAMPLES * 6, 0.02);
|
||||
let _ = c.push(&speech);
|
||||
// Force a partial pending tail so flush exercises the padded-
|
||||
// final-frame branch.
|
||||
let partial = constant_signal(FRAME_SAMPLES / 3, 0.02);
|
||||
let _ = c.push(&partial);
|
||||
|
||||
let _first = c.flush();
|
||||
|
||||
let second = c.flush();
|
||||
assert!(
|
||||
second.is_empty(),
|
||||
"second flush must be a no-op; got {} chunk(s)",
|
||||
second.len()
|
||||
);
|
||||
|
||||
// A subsequent silent feed must emit nothing — proves nothing
|
||||
// about prior speech leaked into the new session's bookkeeping.
|
||||
let silence = constant_signal(FRAME_SAMPLES * 4, 0.0);
|
||||
let chunks = c.push(&silence);
|
||||
assert!(
|
||||
chunks.is_empty(),
|
||||
"post-flush silence must not emit any chunk; got {chunks:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
//! `whisper` feature — `WhisperRsBackend` (direct whisper-rs, the only
|
||||
//! path that pipes `initial_prompt`).
|
||||
|
||||
use kon_core::error::Result;
|
||||
use kon_core::types::{Segment, TranscriptionOptions};
|
||||
use magnotia_core::error::Result;
|
||||
use magnotia_core::types::{Segment, TranscriptionOptions};
|
||||
|
||||
/// Static capabilities a `Transcriber` advertises to callers.
|
||||
///
|
||||
|
||||
@@ -10,8 +10,9 @@ use std::path::Path;
|
||||
|
||||
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::{Segment, TranscriptionOptions};
|
||||
use magnotia_core::constants::inference_thread_count;
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use magnotia_core::types::{Segment, TranscriptionOptions};
|
||||
|
||||
use crate::transcriber::{Transcriber, TranscriberCapabilities};
|
||||
|
||||
@@ -40,7 +41,7 @@ impl WhisperRsBackend {
|
||||
impl Transcriber for WhisperRsBackend {
|
||||
fn capabilities(&self) -> TranscriberCapabilities {
|
||||
TranscriberCapabilities {
|
||||
sample_rate: kon_core::constants::WHISPER_SAMPLE_RATE,
|
||||
sample_rate: magnotia_core::constants::WHISPER_SAMPLE_RATE,
|
||||
channels: 1,
|
||||
supports_initial_prompt: true,
|
||||
}
|
||||
@@ -63,7 +64,7 @@ impl Transcriber for WhisperRsBackend {
|
||||
);
|
||||
|
||||
let mut state = self.ctx.create_state().map_err(|e| {
|
||||
KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string())
|
||||
MagnotiaError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string())
|
||||
})?;
|
||||
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
@@ -77,13 +78,13 @@ impl Transcriber for WhisperRsBackend {
|
||||
params.set_initial_prompt(prompt);
|
||||
}
|
||||
}
|
||||
params.set_n_threads(num_cpus::get() as i32);
|
||||
params.set_n_threads(inference_thread_count() as i32);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
|
||||
state.full(params, samples).map_err(|e| {
|
||||
KonError::TranscriptionFailed(
|
||||
MagnotiaError::TranscriptionFailed(
|
||||
WhisperBackendError::Transcribe(e.to_string()).to_string(),
|
||||
)
|
||||
})?;
|
||||
@@ -98,7 +99,7 @@ impl Transcriber for WhisperRsBackend {
|
||||
let text = seg
|
||||
.to_str()
|
||||
.map_err(|e| {
|
||||
KonError::TranscriptionFailed(
|
||||
MagnotiaError::TranscriptionFailed(
|
||||
WhisperBackendError::Transcribe(e.to_string()).to_string(),
|
||||
)
|
||||
})?
|
||||
|
||||
133
crates/transcription/tests/jfk_bench.rs
Normal file
133
crates/transcription/tests/jfk_bench.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
//! Benchmark: load the JFK WAV from disk, transcribe it via whisper-rs.
|
||||
//! Reports cold-load time, transcribe time, RTF, peak RSS.
|
||||
//!
|
||||
//! Gated on env vars so it never runs in CI without setup:
|
||||
//! MAGNOTIA_WHISPER_TEST_MODEL=/path/to/ggml-tiny.bin
|
||||
//! MAGNOTIA_WHISPER_TEST_AUDIO=/path/to/jfk.wav
|
||||
|
||||
use std::env;
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn jfk_transcription_benchmark() {
|
||||
let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else {
|
||||
eprintln!("MAGNOTIA_WHISPER_TEST_MODEL not set — skipping");
|
||||
return;
|
||||
};
|
||||
let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else {
|
||||
eprintln!("MAGNOTIA_WHISPER_TEST_AUDIO not set — skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
|
||||
|
||||
eprintln!("[bench] loading WAV: {audio_path}");
|
||||
let bytes = std::fs::read(&audio_path).expect("read wav");
|
||||
// Minimal RIFF/WAV parse: skip the 44-byte canonical header for PCM-16-mono-16kHz.
|
||||
// Sanity-check magic bytes + format.
|
||||
assert_eq!(&bytes[0..4], b"RIFF", "expected RIFF");
|
||||
assert_eq!(&bytes[8..12], b"WAVE", "expected WAVE");
|
||||
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 bits = u16::from_le_bytes(bytes[34..36].try_into().unwrap());
|
||||
eprintln!("[bench] wav spec: {} Hz, {} ch, {}-bit", sample_rate, channels, bits);
|
||||
assert_eq!(sample_rate, 16_000, "expected 16 kHz wav");
|
||||
assert_eq!(channels, 1, "expected mono");
|
||||
assert_eq!(bits, 16, "expected 16-bit PCM");
|
||||
|
||||
let pcm = &bytes[44..];
|
||||
let samples: Vec<f32> = pcm
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
|
||||
.collect();
|
||||
let audio_secs = samples.len() as f64 / sample_rate as f64;
|
||||
eprintln!(
|
||||
"[bench] audio length: {} samples = {:.2}s",
|
||||
samples.len(),
|
||||
audio_secs
|
||||
);
|
||||
|
||||
let rss_before_load_kb = read_rss_kb();
|
||||
eprintln!("[bench] RSS before model load: {} MB", rss_before_load_kb / 1024);
|
||||
|
||||
let load_start = Instant::now();
|
||||
let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default())
|
||||
.expect("whisper model load");
|
||||
let load_dur = load_start.elapsed();
|
||||
eprintln!("[bench] model load: {:.2}s", load_dur.as_secs_f64());
|
||||
|
||||
let rss_after_load_kb = read_rss_kb();
|
||||
eprintln!("[bench] RSS after model load: {} MB (delta +{} MB)",
|
||||
rss_after_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 params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_language(Some("en"));
|
||||
params.set_n_threads(6);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
|
||||
// Cold transcription (first run)
|
||||
let cold_start = Instant::now();
|
||||
state.full(params, &samples).expect("transcribe cold");
|
||||
let cold_dur = cold_start.elapsed();
|
||||
|
||||
let n = state.full_n_segments();
|
||||
let mut full_text = String::new();
|
||||
for i in 0..n {
|
||||
let seg = state.get_segment(i).expect("get_segment");
|
||||
full_text.push_str(seg.to_str().unwrap_or(""));
|
||||
}
|
||||
eprintln!(
|
||||
"[bench] cold transcribe: {:.2}s ({} segments, RTF={:.3})",
|
||||
cold_dur.as_secs_f64(),
|
||||
n,
|
||||
cold_dur.as_secs_f64() / audio_secs
|
||||
);
|
||||
eprintln!("[bench] transcript: {}", full_text.trim());
|
||||
let rss_after_cold_kb = read_rss_kb();
|
||||
eprintln!("[bench] RSS after cold xc: {} MB", rss_after_cold_kb / 1024);
|
||||
|
||||
// Warm transcription (second run, same state)
|
||||
let mut state2 = ctx.create_state().expect("whisper state 2");
|
||||
let mut params2 = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params2.set_language(Some("en"));
|
||||
params2.set_n_threads(6);
|
||||
params2.set_print_special(false);
|
||||
params2.set_print_progress(false);
|
||||
params2.set_print_realtime(false);
|
||||
let warm_start = Instant::now();
|
||||
state2.full(params2, &samples).expect("transcribe warm");
|
||||
let warm_dur = warm_start.elapsed();
|
||||
eprintln!(
|
||||
"[bench] warm transcribe: {:.2}s (RTF={:.3})",
|
||||
warm_dur.as_secs_f64(),
|
||||
warm_dur.as_secs_f64() / audio_secs
|
||||
);
|
||||
|
||||
let rss_final_kb = read_rss_kb();
|
||||
eprintln!("[bench] RSS final: {} MB", rss_final_kb / 1024);
|
||||
|
||||
eprintln!("");
|
||||
eprintln!("=== SUMMARY ===");
|
||||
eprintln!("audio: {:.2}s", audio_secs);
|
||||
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!("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);
|
||||
}
|
||||
|
||||
fn read_rss_kb() -> u64 {
|
||||
let pid = std::process::id();
|
||||
let s = std::fs::read_to_string(format!("/proc/{pid}/status")).unwrap_or_default();
|
||||
for line in s.lines() {
|
||||
if let Some(rest) = line.strip_prefix("VmRSS:") {
|
||||
return rest.trim().split_whitespace().next()
|
||||
.and_then(|n| n.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
76
crates/transcription/tests/thread_sweep.rs
Normal file
76
crates/transcription/tests/thread_sweep.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Thread-count scaling sweep for Whisper Tiny.
|
||||
//! Runs the JFK clip at n_threads = 1, 2, 4, 6, 8, 12, prints RTF table.
|
||||
//! Gated on the same env vars as jfk_bench.
|
||||
|
||||
use std::env;
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn whisper_thread_count_sweep() {
|
||||
let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else { return };
|
||||
let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else { return };
|
||||
|
||||
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
|
||||
|
||||
let bytes = std::fs::read(&audio_path).expect("read wav");
|
||||
let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
|
||||
let pcm = &bytes[44..];
|
||||
let samples: Vec<f32> = pcm
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
|
||||
.collect();
|
||||
let audio_secs = samples.len() as f64 / sample_rate as f64;
|
||||
eprintln!("[sweep] audio: {:.2}s @ {} Hz", audio_secs, sample_rate);
|
||||
|
||||
let logical = num_cpus::get();
|
||||
let physical = num_cpus::get_physical();
|
||||
eprintln!("[sweep] CPU: physical={}, logical={}", physical, logical);
|
||||
|
||||
let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default())
|
||||
.expect("model load");
|
||||
|
||||
// Warm-up pass at default to prime caches
|
||||
{
|
||||
let mut state = ctx.create_state().expect("state");
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_language(Some("en"));
|
||||
params.set_n_threads(physical as i32);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
state.full(params, &samples).expect("warmup");
|
||||
}
|
||||
|
||||
let mut targets: Vec<i32> = vec![1, 2, 4, physical as i32, logical as i32];
|
||||
if logical >= 8 && !targets.contains(&8) { targets.push(8); }
|
||||
targets.sort();
|
||||
targets.dedup();
|
||||
|
||||
eprintln!("");
|
||||
eprintln!("=== n_threads scaling ===");
|
||||
eprintln!("n_threads | xc_time | RTF | speedup_vs_1");
|
||||
eprintln!("----------|---------|--------|-------------");
|
||||
let mut baseline_dur: Option<f64> = None;
|
||||
for n in &targets {
|
||||
// Two runs, take the min (deeper of L2/L3 effects; we want best-case)
|
||||
let mut best = f64::MAX;
|
||||
for _ in 0..2 {
|
||||
let mut state = ctx.create_state().expect("state");
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_language(Some("en"));
|
||||
params.set_n_threads(*n);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
let t = Instant::now();
|
||||
state.full(params, &samples).expect("transcribe");
|
||||
let dur = t.elapsed().as_secs_f64();
|
||||
if dur < best { best = dur; }
|
||||
}
|
||||
let rtf = best / audio_secs;
|
||||
let speedup = baseline_dur.map(|b| b / best).unwrap_or(1.0);
|
||||
if baseline_dur.is_none() { baseline_dur = Some(best); }
|
||||
eprintln!("{:>9} | {:>6.2}s | {:>6.3} | {:>6.2}x",
|
||||
n, best, rtf, speedup);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
//! Smoke test: whisper-rs 0.16 loads a GGUF model, transcribes silence, and
|
||||
//! accepts set_initial_prompt without panicking.
|
||||
//!
|
||||
//! Runs only when `KON_WHISPER_TEST_MODEL` is set to the path of a
|
||||
//! Runs only when `MAGNOTIA_WHISPER_TEST_MODEL` is set to the path of a
|
||||
//! ggml/gguf whisper model on disk. Otherwise the test exits quiet.
|
||||
|
||||
use std::env;
|
||||
|
||||
#[test]
|
||||
fn whisper_rs_smoke_loads_and_transcribes() {
|
||||
let model_path = match env::var("KON_WHISPER_TEST_MODEL") {
|
||||
let model_path = match env::var("MAGNOTIA_WHISPER_TEST_MODEL") {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("KON_WHISPER_TEST_MODEL not set — skipping");
|
||||
eprintln!("MAGNOTIA_WHISPER_TEST_MODEL not set — skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
331
docs/audit/phase0-cartography.md
Normal file
331
docs/audit/phase0-cartography.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# Phase 0 — Cartography
|
||||
|
||||
*Acquisition-grade audit, Phase 0 deliverable. Date: 2026-04-30. Branch: `claude/rebrand-to-magnotia-UWYkg`.*
|
||||
|
||||
This is a survey, not a verdict. It maps what exists, sizes the surface, and flags every place the README disagrees with the code. Phase 1 (lean-pass) and Phase 2 (architecture conformance) consume this as input.
|
||||
|
||||
---
|
||||
|
||||
## 1. Workspace shape
|
||||
|
||||
| Layer | Path | Notes |
|
||||
|---|---|---|
|
||||
| Rust workspace root | `Cargo.toml` | `members = ["src-tauri", "crates/*"]`, `resolver = "2"` |
|
||||
| Tauri app crate | `src-tauri/` | Library `magnotia_lib` + binary `magnotia` |
|
||||
| Library crates | `crates/*` (×9) | See §3 |
|
||||
| MCP standalone binary | `crates/mcp/` | Bin `magnotia-mcp` (separate process from main app) |
|
||||
| Svelte frontend | `src/` | SvelteKit, Svelte 5 runes, Tailwind 4 |
|
||||
| Static assets | `static/`, `src-tauri/icons/`, `src-tauri/resources/` | |
|
||||
|
||||
**Binaries shipped (2):** `magnotia` (Tauri app), `magnotia-mcp` (stdio MCP server).
|
||||
|
||||
---
|
||||
|
||||
## 2. Lines of code
|
||||
|
||||
| Area | LOC | Files |
|
||||
|---|---:|---:|
|
||||
| `crates/` (Rust, all 9 crates) | 13,261 | 52 |
|
||||
| `src-tauri/` (Rust) | 8,330 | 27 |
|
||||
| Frontend (Svelte/TS/JS, excl. design-system) | 15,192 | ~80 |
|
||||
| `src/design-system/` (reference kit, not live code) | 1,412 | — |
|
||||
| **Total active** | **~36,800** | |
|
||||
|
||||
### Largest files (top complexity candidates)
|
||||
|
||||
| LOC | File |
|
||||
|---:|---|
|
||||
| 2,534 | `crates/storage/src/database.rs` |
|
||||
| 2,250 | `src/lib/pages/SettingsPage.svelte` |
|
||||
| 1,737 | `src-tauri/src/commands/live.rs` |
|
||||
| 1,185 | `crates/storage/src/migrations.rs` |
|
||||
| 1,081 | `src/lib/pages/DictationPage.svelte` |
|
||||
| 897 | `src/lib/pages/HistoryPage.svelte` |
|
||||
| 790 | `src-tauri/src/commands/paste.rs` |
|
||||
| 735 | `crates/transcription/src/streaming/rms_vad.rs` |
|
||||
| 725 | `src/lib/pages/TasksPage.svelte` |
|
||||
| 720 | `src-tauri/src/commands/models.rs` |
|
||||
| 697 | `src/lib/stores/page.svelte.ts` |
|
||||
|
||||
> **Phase 1 candidate.** `database.rs` (2.5k), `SettingsPage.svelte` (2.25k), and `live.rs` (1.7k) are each large enough to deserve a structural review in isolation. HANDOVER.md already flags `SettingsPage` as needing decomposition into 7 progressive-disclosure groups.
|
||||
|
||||
---
|
||||
|
||||
## 3. Crate inventory
|
||||
|
||||
| Crate | LOC | Files | `pub` items (lib.rs / total) | Tests |
|
||||
|---|---:|---:|---:|---:|
|
||||
| `magnotia-core` | 1,212 | 9 | 10 / 104 | 16 |
|
||||
| `magnotia-audio` | 1,533 | 8 | 14 / 38 | 14 |
|
||||
| `magnotia-transcription` | 2,617 | 12 | 13 / 51 | 51 |
|
||||
| `magnotia-llm` | 1,330 | 6 | 27 / 56 | 17 |
|
||||
| `magnotia-ai-formatting` | 1,502 | 6 | 9 / 21 | 47 |
|
||||
| `magnotia-storage` | 3,771 | 4 | 6 / 69 | 60 |
|
||||
| `magnotia-hotkey` | 632 | 3 | 5 / 14 | 4 |
|
||||
| `magnotia-cloud-providers` | 80 | 2 | 2 / 3 | 2 |
|
||||
| `magnotia-mcp` | 584 | 2 | 8 / 8 | 9 |
|
||||
| `src-tauri` (`magnotia` + `magnotia_lib`) | 8,330 | 27 | n/a | 67 |
|
||||
| **Total** | **21,591** | | | **287** |
|
||||
|
||||
**Outliers worth a Phase-2 look:**
|
||||
- `magnotia-core` exposes 104 public items — high for a "shared types" crate; likely leakage of internals.
|
||||
- `magnotia-storage` exposes 69 public items across only 4 files; the file split is suspect (2.5k-line `database.rs`).
|
||||
- `magnotia-cloud-providers` is 80 LOC and 3 public items — README calls it "empty scaffolding" (verified: just an in-memory keystore + env-var fallback). Either grow it or remove it; it currently earns nothing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Crate dependency graph
|
||||
|
||||
```
|
||||
magnotia-core ──┬─→ magnotia-audio
|
||||
├─→ magnotia-transcription
|
||||
├─→ magnotia-llm ──→ magnotia-ai-formatting
|
||||
├─→ magnotia-cloud-providers
|
||||
├─→ magnotia-hotkey
|
||||
└─→ magnotia-storage ──→ magnotia-mcp
|
||||
|
||||
magnotia (src-tauri)
|
||||
└─→ all 8 library crates (NOT magnotia-mcp — separate binary)
|
||||
```
|
||||
|
||||
**Observations:**
|
||||
- `magnotia-core` is the workspace floor; nothing depends on it depending on something else. Good.
|
||||
- DAG is clean — no cycles, no upward dependencies.
|
||||
- `magnotia-mcp` correctly depends only on `magnotia-storage` (its sole job is to read the SQLite DB). The Tauri app does **not** depend on it, confirming the "separate process" claim in the README.
|
||||
- `magnotia-ai-formatting` depends on both `core` and `llm`. Reasonable.
|
||||
|
||||
> **Phase 2 will verify:** every `pub` item in the leaf crates (`audio`, `transcription`, `llm`, `storage`, `hotkey`) has at least one external consumer. Internal-only items shouldn't be `pub`.
|
||||
|
||||
---
|
||||
|
||||
## 5. External surfaces
|
||||
|
||||
### 5.1 Tauri commands
|
||||
|
||||
**102 `#[tauri::command]` attributes** across **22 of 25** modules in `src-tauri/src/commands/`.
|
||||
- `power.rs` and `security.rs` are utility modules (no commands; helpers only).
|
||||
- `mod.rs` is the registry.
|
||||
|
||||
| Module | # commands | Module | # commands |
|
||||
|---|---:|---|---:|
|
||||
| `tasks` | 12 | `intentions` | 5 |
|
||||
| `models` | 12 | `audio` | 4 |
|
||||
| `llm` | 10 | `tts` | 3 |
|
||||
| `profiles` | 9 | `transcription` | 3 |
|
||||
| `transcripts` | 8 | `paste` | 3 |
|
||||
| `windows` | 8 | `update` | 2 |
|
||||
| `diagnostics` | 6 | `rituals` | 2 |
|
||||
| `hotkey` | 5 | `live` | 2 |
|
||||
| | | `hardware` | 2 |
|
||||
| | | `feedback` | 2 |
|
||||
| | | `nudges`, `meeting`, `fs`, `clipboard` | 1 each |
|
||||
|
||||
**Every one of these is a trust boundary** — Phase 4 (security) will audit input validation per command.
|
||||
|
||||
### 5.2 MCP tools (read-only stdio)
|
||||
|
||||
Confirmed in `crates/mcp/src/lib.rs`:
|
||||
- `list_transcripts`
|
||||
- `get_transcript`
|
||||
- `search_transcripts`
|
||||
- `list_tasks`
|
||||
|
||||
The `init_readonly` connection mode in `magnotia-storage` is opened at OS level (`SQLITE_OPEN_READONLY`), per `crates/mcp/src/main.rs:18` — Phase 4 will confirm with a write-attempt test.
|
||||
|
||||
### 5.3 Frontend route surface
|
||||
|
||||
| Route | Purpose | Layout |
|
||||
|---|---|---|
|
||||
| `/` | Main dictation shell | `+layout.svelte` (sidebar + chrome) |
|
||||
| `/float` | Tasks float window | `+layout@.svelte` (chrome-free) |
|
||||
| `/viewer` | Transcript editor | `+layout@.svelte` (chrome-free) |
|
||||
| `/preview` | Live transcription overlay | `+layout@.svelte` (chrome-free) |
|
||||
|
||||
**Pages:** `DictationPage`, `SettingsPage`, `HistoryPage`, `TasksPage`, `FilesPage`, `FirstRunPage`, `ShutdownRitualPage` (7).
|
||||
|
||||
**Stores:** `page`, `preferences`, `profiles`, `toasts`, `focusTimer`, `llmStatus`, `nudgeBus`, `implementationIntentions`, `completionStats`, `speaker` (10).
|
||||
|
||||
**Components:** 25 in `src/lib/components/`.
|
||||
|
||||
**i18n locales:** `en`, `es`, `de` (scaffolding only — most strings are still hard-coded; the migration is incremental per README).
|
||||
|
||||
### 5.4 Model registry
|
||||
|
||||
7 models declared in `crates/core/src/model_registry.rs`:
|
||||
- Whisper (6): `whisper-tiny-en`, `whisper-base-en`, `whisper-small-en`, `whisper-distil-small-en`, `whisper-medium-en`, `whisper-distil-large-v3`
|
||||
- Parakeet (1): `parakeet-ctc-0.6b-int8`
|
||||
|
||||
> Moonshine is mentioned in the README's `magnotia-core` description ("Moonshine entries") but **no Moonshine entry exists in the registry**. See §7.
|
||||
|
||||
---
|
||||
|
||||
## 6. Test floor
|
||||
|
||||
| Location | Count |
|
||||
|---|---:|
|
||||
| `crates/*/src/` (lib tests) | 217 |
|
||||
| `crates/*/tests/` (integration) | 3 |
|
||||
| `src-tauri/src/` + `src-tauri/tests/` | 67 |
|
||||
| **Total** | **287** |
|
||||
|
||||
Only 3 cross-crate integration tests is light. Per-crate lib tests dominate. Phase 5 (test integrity) will mutation-test the heavy crates (`storage`, `transcription`, `llm`) to grade whether these tests actually pin behaviour.
|
||||
|
||||
---
|
||||
|
||||
## 7. README ↔ code drift
|
||||
|
||||
Items where the README disagrees with the code as-of this audit:
|
||||
|
||||
| README claim | Reality | Severity |
|
||||
|---|---|---|
|
||||
| "245 automated lib tests across 10 crates" (line 14) | **287 tests** total (220 lib + 67 src-tauri); 220 lib-only | LOW — undercount (good direction, but stale) |
|
||||
| "10 crates" (line 14) | 9 library crates + 1 app crate (`src-tauri`) — depends how you count; technically the workspace has 10 packages | OK if counting `src-tauri`; misleading otherwise |
|
||||
| "Commands: audio, clipboard, diagnostics, hotkey, live, llm, meeting, models, paste, power, profiles, tasks, transcription, transcripts, update, windows" (line 95-97) — 16 listed | **22 modules with commands**: README missing `feedback`, `fs`, `intentions`, `nudges`, `rituals`, `tts`. Architecture diagram and §"Tauri commands" table both stale. | **MED** — visible to anyone evaluating the codebase |
|
||||
| "18 Tauri command modules" (line 117) | **25 files** in `commands/` (22 with command attrs + `mod`, `power`, `security`) | MED |
|
||||
| `magnotia-core` "model registry (Whisper + Parakeet + Moonshine entries)" (line 165) | **No Moonshine entries** in `model_registry.rs`. 6 Whisper + 1 Parakeet only. | **MED** — claims an unimplemented feature |
|
||||
| Stores listed: `settings, profiles, tasks, history, taskLists, templates, page, toasts, preferences` (line 202) | Actual stores: `page, preferences, profiles, toasts, focusTimer, llmStatus, nudgeBus, implementationIntentions, completionStats, speaker`. README list is **largely fictional** — there is no `tasks`, `history`, `taskLists`, `templates`, or `settings` store as a separate file. | **HIGH** — describes architecture that doesn't exist |
|
||||
| `magnotia-cloud-providers` "BYOK cloud-STT provider stubs… Currently empty scaffolding. When populated: OpenAI-compatible endpoint + Anthropic" (line 172) | Crate has an in-memory API-key store with env-var fallback — not "empty scaffolding". No HTTP code, no provider implementations. | LOW — partial |
|
||||
| "Every new workspace crate needs a `description` in its `Cargo.toml`" (line 362, contributing rule) | **`crates/llm/Cargo.toml` has no `description` field.** Self-violation of the contribution rule. | LOW — easy fix |
|
||||
| README §"Architecture" Rust crate list spelling (line 102-104) | Correct, but the manual line-break formatting got mangled by the rebrand sweep — visible whitespace inconsistency. | TRIVIAL |
|
||||
|
||||
> **Phase 0 verdict on documentation truth:** The README is **mostly right but actively misleading in two places** — the stores list and the Moonshine claim. Both will fail an acquirer's first sanity-check (`grep -r "Moonshine" crates/`) and erode trust in the rest of the doc.
|
||||
|
||||
---
|
||||
|
||||
## 8. HANDOVER files
|
||||
|
||||
| File | Date | Topic | Status |
|
||||
|---|---|---|---|
|
||||
| `HANDOVER.md` | 2026-04-25 | Latest session (Phase 9a–9d) | Active reference |
|
||||
| `HANDOVER-2026-04-24.md` | 2026-04-24 | Phase 8 close | Historical |
|
||||
| `HANDOVER-2026-04-19.md` | 2026-04-19 | Earlier session | Historical |
|
||||
| `HANDOVER-2026-04-18.md` | 2026-04-18 | Earlier session | Historical |
|
||||
| `HANDOVER-2026-04-17.md` | 2026-04-17 | Earliest in tree | Historical |
|
||||
|
||||
Five handovers in the repo root is unusual — most projects keep one. Phase 8 (docs truth) will recommend either archiving them under `docs/handovers/` or rotating to a single `HANDOVER.md` with prior content moved.
|
||||
|
||||
The post-rebrand state: all five handovers were rewritten by today's sweep (line counts identical, words different). They now reference `magnotia` paths but their **content** describes work done under the `kon` / `corbie` names — there's a temporal-vs-naming mismatch a reader has to mentally track. Acquirer-friendly fix: add a one-line note at the top of each historical handover saying "Originally written when the product was named X; references rewritten 2026-04-30."
|
||||
|
||||
---
|
||||
|
||||
## 9. Fix areas — actionable tasks
|
||||
|
||||
Each task below is concrete: file, change, verification, effort. Pick any in any order; they don't depend on later phases. Items are grouped by impact tier.
|
||||
|
||||
### Tier A — High impact, do first
|
||||
|
||||
#### A1. README stores list is fiction
|
||||
- **File:** `README.md`, line 202
|
||||
- **Current:** `Reactive stores (src/lib/stores/page.svelte.ts): settings, profiles, tasks, history, taskLists, templates, page, toasts, preferences.`
|
||||
- **Reality:** stores are `page`, `preferences`, `profiles`, `toasts`, `focusTimer`, `llmStatus`, `nudgeBus`, `implementationIntentions`, `completionStats`, `speaker` — each in its own `*.svelte.ts` file under `src/lib/stores/`.
|
||||
- **Fix:** rewrite the bullet to enumerate the actual ten store files, and clarify that `page.svelte.ts` is the central app-state store (transcripts, profiles, taskLists, etc. live as fields on it).
|
||||
- **Verify:** `ls src/lib/stores/` matches the README list 1:1.
|
||||
- **Effort:** 10 min.
|
||||
|
||||
#### A2. Moonshine claim has no implementation
|
||||
- **File:** `README.md`, line 165 (`magnotia-core` row in the crate table)
|
||||
- **Current:** `model registry (Whisper + Parakeet + Moonshine entries)`
|
||||
- **Reality:** `crates/core/src/model_registry.rs` has 6 Whisper + 1 Parakeet entries. Zero Moonshine.
|
||||
- **Fix (pick one):**
|
||||
- (a) Remove the Moonshine reference from the README. Cheapest.
|
||||
- (b) Add a `// TODO(moonshine): not yet wired` constant in `model_registry.rs` and a roadmap entry under §Roadmap, so the claim is at least flagged as forthcoming.
|
||||
- **Verify:** `grep -ri moonshine crates/ src-tauri/ src/` returns no orphan references.
|
||||
- **Effort:** 5 min (option a) / 30 min (option b).
|
||||
|
||||
#### A3. Six Tauri command modules undocumented
|
||||
- **File:** `README.md`, lines 95-97 (Architecture diagram) and 175-195 (Tauri commands table)
|
||||
- **Missing:** `feedback`, `fs`, `intentions`, `nudges`, `rituals`, `tts`
|
||||
- **Fix:** add a one-line description for each in the §Tauri commands table; add the names to the Architecture-diagram bullet list.
|
||||
- **Verify:** `ls src-tauri/src/commands/*.rs | xargs basename -s .rs | sort` matches the README table 1:1 (excluding `mod`, `power`, `security`, which are utility modules — note that explicitly).
|
||||
- **Effort:** 20 min.
|
||||
|
||||
### Tier B — Low effort, removes self-violations
|
||||
|
||||
#### B1. `crates/llm/Cargo.toml` missing `description`
|
||||
- **File:** `crates/llm/Cargo.toml`
|
||||
- **Current:** `[package]` block has `name`, `version`, `edition` only.
|
||||
- **Reality:** README §Contributing line 362 declares this a hard rule. Self-violation.
|
||||
- **Fix:** add `description = "Local LLM engine for Magnotia (Qwen3 via llama-cpp-2). Cleanup, task extraction, content tags."` (or similar). Match the prose style of the other 8 crates' descriptions.
|
||||
- **Verify:** `for d in crates/*/Cargo.toml src-tauri/Cargo.toml; do grep -L "^description" "$d"; done` returns empty.
|
||||
- **Effort:** 2 min.
|
||||
|
||||
#### B2. Stale test-count claim
|
||||
- **File:** `README.md`, line 14
|
||||
- **Current:** `245 automated lib tests across 10 crates, all passing`
|
||||
- **Reality:** 287 tests total (220 lib + 67 src-tauri); 220 lib-only.
|
||||
- **Fix:** decide on a number that's automatable, not a snapshot. Either: (a) replace with `220+ lib tests across 9 library crates plus 67 Tauri-app tests`, or (b) drop the absolute number and say `comprehensive automated test floor — see CI for current count`.
|
||||
- **Verify:** `grep -rE '#\[(test|tokio::test)\]' crates/*/src/ | wc -l` matches whatever number you ship.
|
||||
- **Effort:** 5 min.
|
||||
|
||||
#### B3. Crate count claim ambiguity
|
||||
- **File:** `README.md`, line 14 ("10 crates")
|
||||
- **Reality:** 9 library crates + 1 Tauri app crate. The README's own crate table only documents 9.
|
||||
- **Fix:** say "9 library crates plus the Tauri app crate" — or just "9 library crates" and let the Tauri app stand separately, which matches the existing prose.
|
||||
- **Effort:** 2 min.
|
||||
|
||||
### Tier C — Structural smells (defer to Phase 2 but flag now)
|
||||
|
||||
#### C1. `magnotia-core` over-exports
|
||||
- **File(s):** `crates/core/src/lib.rs` and the modules it re-exports
|
||||
- **Symptom:** 104 public items in a "shared types" crate. High blast radius for any change.
|
||||
- **Fix (Phase 2 work, do not touch yet):** audit every `pub` item; demote anything not used outside the crate to `pub(crate)`. The expected outcome is a 30–60% reduction in public surface.
|
||||
- **Verify:** after the demotion pass, `cargo +nightly rustdoc` should still succeed and downstream crates should still compile without changes.
|
||||
- **Effort:** ~½ day (Phase 2 scope).
|
||||
|
||||
#### C2. `magnotia-storage::database.rs` is 2,534 lines
|
||||
- **File:** `crates/storage/src/database.rs`
|
||||
- **Symptom:** single file holds CRUD for transcripts, tasks, subtasks, profiles, profile-terms, settings, error log, FTS5. No internal module boundaries.
|
||||
- **Fix (Phase 2):** split by domain — `database/transcripts.rs`, `database/tasks.rs`, `database/profiles.rs`, etc. Keep the public re-export shape unchanged so callers don't move.
|
||||
- **Verify:** `cargo test -p magnotia-storage` still passes; no public-API changes.
|
||||
- **Effort:** ~2-4 hours.
|
||||
|
||||
#### C3. `SettingsPage.svelte` is 2,250 lines
|
||||
- **File:** `src/lib/pages/SettingsPage.svelte`
|
||||
- **Symptom:** HANDOVER.md already flags this; `SettingsGroup.svelte` was prepared but the seven-group split was deferred.
|
||||
- **Fix (Phase 2):** complete the planned restructure. Pick this up from HANDOVER.md §"9c — Settings (scaled down)".
|
||||
- **Effort:** ~½ day.
|
||||
|
||||
#### C4. `magnotia-cloud-providers` does not earn its existence
|
||||
- **Files:** `crates/cloud-providers/` (80 LOC across 2 files)
|
||||
- **Symptom:** crate contains an in-memory keystore with env-var fallback. Not "empty scaffolding" as the README says — but also not provider-specific. No HTTP code, no providers.
|
||||
- **Fix (decide, then act):**
|
||||
- (a) **Fold** into `magnotia-core::keystore` (preferred — it's a generic key store, nothing cloud-specific). Drop the crate. README §Architecture and the dependency graph simplify.
|
||||
- (b) **Grow** it: actually implement an OpenAI-compatible STT client and an Anthropic STT client, gated behind a `cloud-stt` feature flag. Earn the boundary.
|
||||
- **Verify (option a):** workspace builds with `cloud-providers` removed from `Cargo.toml` members; the two consumers (`commands/llm.rs` and wherever else) re-import from `magnotia-core::keystore`.
|
||||
- **Effort:** ~1 hour (option a) / multi-day (option b).
|
||||
|
||||
### Tier D — Hygiene (Phase 1 / Phase 8)
|
||||
|
||||
#### D1. Five HANDOVER files in repo root
|
||||
- **Files:** `HANDOVER.md`, `HANDOVER-2026-04-{17,18,19,24}.md`
|
||||
- **Symptom:** root noise; rebrand also rewrote their content so they describe `kon`/`corbie` work but read as `magnotia`.
|
||||
- **Fix:**
|
||||
- Move the four dated files under `docs/handovers/`.
|
||||
- Add a one-line italic note at the top of each historical file: *"Originally written when the product was named Kon (and briefly Corbie); references rewritten in the 2026-04-30 rebrand sweep."*
|
||||
- Keep the latest as `HANDOVER.md` in root, or also move under `docs/handovers/HANDOVER-latest.md` with a symlink — pick one.
|
||||
- **Effort:** 15 min.
|
||||
|
||||
#### D2. Tauri command total count drift
|
||||
- **README** says "18 Tauri command modules" (line 117); actual is 22 modules with commands (plus 3 utility modules in the same dir).
|
||||
- **Fix:** update line 117 to "22 Tauri command modules + 3 utility modules (`mod`, `power`, `security`)".
|
||||
- **Effort:** 1 min — usually folded into A3.
|
||||
|
||||
---
|
||||
|
||||
## 10. Phase 1 entry plan
|
||||
|
||||
Tier A and Tier B fixes above (≈45 min total) bring the README back into truth and close the self-imposed Cargo.toml rule. Do these as a warm-up before Phase 1 proper; they make every subsequent phase' "what does the README say?" comparison cheaper.
|
||||
|
||||
Phase 1 (Lean-pass) — see [`phases-1-8-playbook.md`](phases-1-8-playbook.md) — should then target, in order:
|
||||
|
||||
1. `cargo machete` + `cargo udeps` workspace-wide → unused deps kill list.
|
||||
2. `knip` on the frontend → unused TS/Svelte modules.
|
||||
3. Manual review of the **5 files >1k LOC** for duplicate logic (`SettingsPage.svelte`, `database.rs`, `live.rs`, `migrations.rs`, `DictationPage.svelte`).
|
||||
4. Grep audit of `TODO` / `FIXME` / `unimplemented!` / `unwrap()` outside tests → tech-debt log.
|
||||
5. Apply the Tier C structural smells if Phase 2 is being done immediately afterwards.
|
||||
|
||||
Estimated time: **1 working day** for Phase 1 in full, plus ~45 min of Tier A/B fixes.
|
||||
|
||||
---
|
||||
|
||||
*End of Phase 0 cartography.*
|
||||
331
docs/audit/phase1-lean-pass.md
Normal file
331
docs/audit/phase1-lean-pass.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# Phase 1 — Lean-pass (scan-only deliverable)
|
||||
|
||||
*Read-only deliverable; no code changes applied. Removals and refactors deferred to a review pass.*
|
||||
|
||||
Date: 2026-05-01. Branch: `main` @ `7ff7295` (working tree clean at scan start; a parallel agent is committing the Phase 0 §9 Tier A/B/D1 README and `Cargo.toml` documentation fixes alongside this scan). Status: scan-only.
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
This is the find-first half of audit discipline. Every section below is a scan output, lightly categorised. No code was modified to produce this report; remediation gets a separate commit and review pass after Jake walks the findings. The Phase 1 playbook (`docs/audit/phases-1-8-playbook.md` §Phase 1) calls for `cargo machete`, `cargo udeps`, `knip`, `depcheck`, dead-code lints per crate, a tech-debt grep, an `unwrap`/`expect` panic-surface scan, manual notes on the five files >1k LOC, and `jscpd` cross-file duplication. All steps were attempted; toolchain gaps are logged in §"Scans deferred".
|
||||
|
||||
Severity grades (per playbook): **P0** must-fix before any release; **P1** must-fix before sale or public beta; **P2** worth fixing, not blocking.
|
||||
|
||||
---
|
||||
|
||||
## 1. Unused Rust dependencies
|
||||
|
||||
`cargo machete` (v0.9.2, freshly installed, recursive workspace mode) flags **4 unused-dependency hits across 4 crates**.
|
||||
|
||||
| ID | Severity | Crate | `Cargo.toml` declares | Notes |
|
||||
|---|---|---|---|---|
|
||||
| L1.1 | P2 | `magnotia-cloud-providers` | `magnotia-core` | Crate is 80 LOC of in-memory keystore; no `magnotia_core::` imports in source. Likely dead since cartography §3 ("not earning its existence"). Cross-references Phase 0 Tier C4. |
|
||||
| L1.2 | P2 | `magnotia-core` | `async-trait` | Not used inside `core`'s own modules. Worth confirming with reverse-grep before removal — `async_trait` is sometimes pulled in by macro expansion only. |
|
||||
| L1.3 | P2 | `magnotia-core` | `serde_json` | Same caveat — many crates pull `serde_json` for downstream re-export, but `core` should not need it directly. |
|
||||
| L1.4 | P2 | `magnotia-hotkey` | `magnotia-core` | Hotkey crate compiles standalone; only depends on `core` for shared error types presumably, but the import is not present. |
|
||||
| L1.5 | P2 | `magnotia` (`src-tauri`) | `magnotia-cloud-providers` | The Tauri crate declares the cloud-providers crate, but no `magnotia_cloud_providers::` symbol appears in `src-tauri/src`. Folds into Phase 0 Tier C4 (kill or grow `cloud-providers`). |
|
||||
|
||||
**Cross-check with `cargo udeps`:** *not run.* The repo currently has only a stable Rust toolchain installed (`rustup toolchain list` returned `stable-x86_64-unknown-linux-gnu` only). `cargo udeps` requires nightly. Logged under Scans deferred.
|
||||
|
||||
**Recommended Phase-1 follow-up:** before deleting any of these, do a workspace-wide reverse grep for each dep name; macros and re-exports defeat machete. The L1.4 and L1.5 hits in particular suggest `magnotia-cloud-providers` as a unit is removable (cartography Tier C4 option a — fold into `magnotia-core::keystore`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Unused frontend modules
|
||||
|
||||
### 2.1 `npx knip`
|
||||
|
||||
**Files reported as unused (9):**
|
||||
|
||||
| ID | Severity | File | Notes |
|
||||
|---|---|---|---|
|
||||
| L2.1 | P2 | `src/app.d.ts` | SvelteKit ambient declaration. Knip almost always false-positives on these; **keep**. |
|
||||
| L2.2 | P2 | `src/design-system/colors_and_type.css` | Reference design-system, not live code (cartography §2 already excludes the design-system from active LOC). Verify before deletion. |
|
||||
| L2.3 | P2 | `src/design-system/ui_kits/DictationPage.jsx` | Reference UI kit (JSX in a Svelte project — clearly a sketch, not a build target). |
|
||||
| L2.4 | P2 | `src/design-system/ui_kits/OtherPages.jsx` | As above. |
|
||||
| L2.5 | P2 | `src/design-system/ui_kits/Sidebar.jsx` | As above. |
|
||||
| L2.6 | P2 | `src/lib/components/VirtualSegmentList.svelte` | Possible orphan after a refactor. Worth a `grep -r VirtualSegmentList src/` to confirm before removal. |
|
||||
| L2.7 | P2 | `src/lib/components/VisualTimer.svelte` | Same — verify no dynamic import or string-named lookup. |
|
||||
| L2.8 | P2 | `src/lib/shims.d.ts` | TypeScript shim. Knip false-positive class; **keep**. |
|
||||
| L2.9 | P2 | `static/pcm-processor.js` | AudioWorklet module — loaded by URL string at runtime, not by import. **Keep** (false positive). |
|
||||
|
||||
**Unused dependencies (3):**
|
||||
|
||||
| ID | Severity | Package | Notes |
|
||||
|---|---|---|---|
|
||||
| L2.10 | P2 | `@tauri-apps/plugin-autostart` | Likely registered Rust-side in `src-tauri/Cargo.toml` and configured via `tauri.conf.json` rather than imported from JS. Verify — if the Rust side uses it, the JS dep can go. |
|
||||
| L2.11 | P2 | `@tauri-apps/plugin-global-shortcut` | Same — Rust-side registration. |
|
||||
| L2.12 | P2 | `@tauri-apps/plugin-opener` | Same. |
|
||||
|
||||
**Unlisted binaries (1):**
|
||||
|
||||
| ID | Severity | Reference | Notes |
|
||||
|---|---|---|---|
|
||||
| L2.13 | P2 | `du` in `.github/workflows/build.yml` | Just a system tool used in CI. Informational; nothing to do. |
|
||||
|
||||
**Unused exports (31) and exported types (28):** see `/tmp/claude-1000/.../bq57g56il.output` for the full list. The high-density file is `src/lib/utils/textMeasure.ts` (5 exports, none consumed) and `src/lib/utils/settingsMigrations.ts` (3 exports + 2 types). Treat each as a candidate for either consumption or deletion. **Severity P2 across the board** — the exports are dead but harmless; their LOC saving is real but the risk of collateral damage is nonzero (they could be imported by name from another package or via dynamic lookup). Manual review per-file in Phase 2.
|
||||
|
||||
### 2.2 `npx depcheck`
|
||||
|
||||
Run with `--skip-missing` to avoid false positives on dev tooling.
|
||||
|
||||
| ID | Severity | Package | Notes |
|
||||
|---|---|---|---|
|
||||
| L2.14 | P2 | `@tauri-apps/plugin-autostart` | Confirms L2.10. |
|
||||
| L2.15 | P2 | `@tauri-apps/plugin-global-shortcut` | Confirms L2.11. |
|
||||
| L2.16 | P2 | `@tauri-apps/plugin-opener` | Confirms L2.12. |
|
||||
| L2.17 | P2 | `lucide-svelte` | **Worth investigating.** Declared but depcheck found no JS-side import. If icons are inlined as SVG, the dep is genuinely dead. If imported via a wrapper component, false positive. |
|
||||
| L2.18 | P2 | `tailwindcss` (devDep) | Almost certainly a false positive — Tailwind is wired in via `@tailwindcss/vite` plugin in `vite.config.js` rather than a direct import. **Keep.** |
|
||||
|
||||
**Convergent signal:** L2.10/11/12 plus L2.14/15/16 — both tools agree the three Tauri plugins are JS-unused. They are still required Rust-side (the Tauri JS bridge ships separately from the Rust plugins), so the safe pattern is to keep them only if the JS API is invoked from the frontend. Quick `grep -r 'autostart\|globalShortcut\|opener' src/` answers it; that is a Phase 2 pass, not a Phase 1 deletion.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dead Rust code
|
||||
|
||||
Each crate was rebuilt (workspace + `--all-targets`) with `RUSTFLAGS="-W dead_code -W unused"` to surface the strictest defaults.
|
||||
|
||||
**Result:** **zero warnings emitted across all nine library crates and `src-tauri`.**
|
||||
|
||||
This is genuine — both `cargo build --workspace` and `cargo build --workspace --all-targets` came back clean with the elevated flags. The codebase does not carry obvious unreachable Rust code, unused imports, or unused private items as scored by rustc's default dead-code detector. Stronger lints (`clippy::pedantic`, `clippy::nursery`) are deferred to Phase 3 per the playbook.
|
||||
|
||||
**Caveat:** rustc's `dead_code` only fires when a `pub` item has no callers *anywhere in the same crate*; cross-crate dead `pub` items (where the only callers are inside a sibling crate that has since stopped using them) require the workspace-wide reverse-grep pass scheduled for Phase 2 step 5 (the `magnotia-core` 104-public-items audit). The L1.* dependency hits above are early evidence that some inter-crate links are already dormant.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tech-debt grep
|
||||
|
||||
`grep -rnE "TODO|FIXME|HACK|XXX|unimplemented!\(\)|todo!\(\)" --include="*.rs" --include="*.svelte" --include="*.ts"` returned **5 hits** total — a remarkably low count for a 36k-LOC codebase.
|
||||
|
||||
| ID | Severity | File:line | Marker | Bucket | Suggested action |
|
||||
|---|---|---|---|---|---|
|
||||
| L4.1 | P2 | `crates/cloud-providers/src/keystore.rs:13` | TODO | (a) genuine reminder | Replace process-local `Mutex<HashMap>` keystore with `keyring` crate or platform-native credential storage so secrets persist across sessions. Cross-references Phase 0 Tier C4 (and the Plinth `MEMORY.md` note about plaintext Foundry secrets — same family of issue). Log to issue tracker. |
|
||||
| L4.2 | P2 | `crates/storage/src/database.rs:166` | (TODO) | (b) historical comment | Already-resolved TODO referenced in a doc comment ("the rename was a UI-only state change with a TODO never wired up"). Reads as historical narrative; not actionable but cluttery. Optional: shorten the comment in a docs pass. |
|
||||
| L4.3 | P1 | `crates/storage/src/database.rs:1124` | TODO | (c) silent admission | `log_error` is implemented but the doc comment states it is *not yet wired into Tauri command error paths*. Result: every command's error is converted to a `String` and returned to the JS layer, but the persistent `error_log` table is never written to from production paths. **This is real audit signal**: the README and crate surface advertise an error-log capability that is dormant. Wire it into the command-layer `?` translations in Phase 3. |
|
||||
| L4.4 | P2 | `src/lib/components/MorningTriageModal.svelte:262` | TODO | (a) genuine reminder | Promote inline `#1a1816` 50%-alpha overlay colour to a `--color-overlay` token in `app.css`. Cosmetic; safe to action in Phase 8 (docs/style truth) or whenever a token-pass happens. |
|
||||
| L4.5 | P2 | `src-tauri/src/commands/transcripts.rs:6` | TODO (in narrative comment) | (b) historical | Comment narrates that a previous TODO ("persist to SQLite when update_transcript exists") has been resolved. Like L4.2: historical, not actionable. Optional cleanup. |
|
||||
|
||||
**Bucket totals:** 1 × bucket (a), 2 × bucket (b), 1 × bucket (c, escalated to P1). Zero `FIXME`, zero `HACK`, zero `XXX`, zero `unimplemented!()`, zero `todo!()` in the entire active codebase. That last point is itself a strong-positive finding — see §Defect log summary.
|
||||
|
||||
---
|
||||
|
||||
## 5. Panic surface (`unwrap` / `expect` outside tests)
|
||||
|
||||
The raw grep returned **385 hits**, but a context-aware Python pass (which tracks `#[test]`, `#[tokio::test]`, and `#[cfg(test)] mod`) finds that **359 of those are inside test bodies or `#[cfg(test)]` modules** and **only 26 sit on production paths**. Production list below.
|
||||
|
||||
| File:line | Context (1 line) | Risk note |
|
||||
|---|---|---|
|
||||
| `crates/mcp/src/lib.rs:230` | `serde_json::to_string_pretty(&summaries).unwrap()` | Serialising a `Vec<TranscriptSummary>` we just built; cannot fail in practice but the `.unwrap()` will panic the MCP stdio worker on any bug in the type. Trade for `?`. |
|
||||
| `crates/mcp/src/lib.rs:262` | `text_content(serde_json::to_string_pretty(&value).unwrap())` | Same family — JSON-stringifying a value of our own construction. |
|
||||
| `crates/mcp/src/lib.rs:294` | `serde_json::to_string_pretty(&summaries).unwrap()` | Same. |
|
||||
| `crates/mcp/src/lib.rs:319` | `serde_json::to_string_pretty(&summaries).unwrap()` | Same. |
|
||||
| `crates/llm/src/lib.rs:102` | `let mut guard = self.inner.lock().unwrap()` | Standard `Mutex::lock().unwrap()` — only panics on poison. Acceptable in practice but a poisoned LLM mutex would panic the whole engine; an explicit `expect("LLM engine mutex poisoned")` would at least name the failure. |
|
||||
| `crates/llm/src/lib.rs:137` | `let mut guard = self.inner.lock().unwrap()` | Same. |
|
||||
| `crates/llm/src/lib.rs:145` | `self.inner.lock().unwrap().model.is_some()` | Same. |
|
||||
| `crates/llm/src/lib.rs:149` | `self.inner.lock().unwrap().loaded.clone()` | Same. |
|
||||
| `crates/llm/src/lib.rs:169` | `NonZeroU32::new(n_ctx).expect("n_ctx must be non-zero")` | Construction-time invariant; preceded by a guard that should rule out zero. Fine, but the invariant is not encoded — if a caller bypasses the guard, this panics on engine init. Reasonable to keep with the `expect`. |
|
||||
| `crates/llm/src/lib.rs:380` | `let guard = self.inner.lock().unwrap()` | Mutex poison. |
|
||||
| `crates/transcription/src/streaming/commit_policy.rs:125` | `self.history.back().expect("history is non-empty here")` | The expect string asserts a precondition that is enforced four lines above by `if self.history.is_empty() { return … }`. **Defensible** — the panic message even names the invariant. Document in a Phase-3 inline justification rather than refactor. |
|
||||
| `crates/cloud-providers/src/keystore.rs:18` | `api_key_store().lock().unwrap()` | Mutex poison; same family as the `llm` ones. |
|
||||
| `crates/cloud-providers/src/keystore.rs:31` | `api_key_store().lock().unwrap()` | Same. |
|
||||
| `src-tauri/src/lib.rs:442` | `.expect("error while running Magnotia")` | The Tauri `run()` call. A panic here is the conventional pattern (no recovery anyway — process exit). Keep. |
|
||||
| `src-tauri/src/commands/power.rs:61` | `.unwrap()` | Power-assertion mutex lock. Mutex poison. |
|
||||
| `src-tauri/src/commands/power.rs:103` | `assertion_registry().lock().unwrap().insert(…)` | Mutex poison. |
|
||||
| `src-tauri/src/commands/power.rs:134` | `assertion_registry().lock().unwrap().remove(&self.id)` | Mutex poison. |
|
||||
| `src-tauri/src/commands/audio.rs:159` | `all_samples.lock().unwrap().clear()` | Mutex poison. |
|
||||
| `src-tauri/src/commands/audio.rs:165` | `*state.wav_writer.lock().unwrap() = Some(writer)` | Mutex poison. |
|
||||
| `src-tauri/src/commands/audio.rs:166` | `*state.temp_audio_path.lock().unwrap() = Some(temp_path)` | Mutex poison. |
|
||||
| `src-tauri/src/commands/audio.rs:316` | `let mut all = state.all_samples.lock().unwrap()` | Mutex poison. |
|
||||
| `src-tauri/src/commands/live.rs:330` | `self.state.resampler.as_mut().expect("resampler just set")` | Local invariant immediately after a `set` — defensible. |
|
||||
| `src-tauri/src/commands/live.rs:497` | `let running = live_state.running.lock().unwrap()` | Mutex poison. |
|
||||
| `src-tauri/src/commands/live.rs:581` | `*live_state.running.lock().unwrap() = Some(RunningLiveSession {…})` | Mutex poison. |
|
||||
| `src-tauri/src/commands/live.rs:600` | `let running = live_state.running.lock().unwrap().take()` | Mutex poison. |
|
||||
| `src-tauri/src/commands/live.rs:606` | `*live_state.running.lock().unwrap() = Some(running)` | Mutex poison. |
|
||||
|
||||
**Pattern summary.** The 26 production unwraps cluster into three categories:
|
||||
|
||||
- **Mutex poison** (19 occurrences): `Mutex::lock().unwrap()`. The well-known Rust idiom; only fails if a thread panicked while holding the lock. Acceptable but **inconsistent**: every site should at minimum carry an `expect("&str describing what")` so a poison-panic crash log names the lock. Phase 3 task: standardise.
|
||||
- **Self-built JSON serialisation** (4 occurrences in `magnotia-mcp`): `serde_json::to_string_pretty(&value).unwrap()`. Cannot fail for our own types in practice, but the playbook's "prove it can't panic on user data" rule says these should become `?` or carry explicit `expect`s. Phase 3 task.
|
||||
- **Locally-enforced invariants** (3 occurrences in `commit_policy`, `live`, `lib.rs`): the line above the `unwrap`/`expect` enforces the precondition. These are the most defensible; Phase 3 should add an inline `// SAFETY: …` comment naming the invariant rather than rewrite.
|
||||
|
||||
**Severity:** all P2 in isolation. Collectively → P1 to standardise before sale; an acquirer reading this list will want the convention named.
|
||||
|
||||
---
|
||||
|
||||
## 6. Largest-file duplicate-logic notes
|
||||
|
||||
### 6.1 `src/lib/pages/SettingsPage.svelte` — 2,250 LOC
|
||||
|
||||
Confirmed seven `<SettingsGroup>` top-level groups (lines 972, 1020, 1379, 1569, 1808, 1947, 2119) corresponding to the planned seven-group split documented in HANDOVER.md §"9c — Settings (scaled down)". The structural decomposition is **already in source** — what remains is the file split: each top-level `<SettingsGroup>` should move to its own `*.svelte` file under `src/lib/pages/settings/`. The page-level component then becomes a slim shell ~200 LOC.
|
||||
|
||||
**Inline observations:**
|
||||
- 49 `<button>` elements in a single component is a smell — many will share handlers via prop drilling that a per-section split would localise.
|
||||
- 57 declared `function` / `async function` blocks at the top level. Most are single-section helpers (model download, vocab management, audio device polling) and would migrate cleanly with their owning `<SettingsGroup>`.
|
||||
- `let visibleAudioDevices = $derived(buildVisibleDevices(audioDevices))` (line 142) and the `buildVisibleDevices` helper (lines 96-142) are pure-functional and worth lifting into `src/lib/utils/audioDevices.ts` for unit-testing.
|
||||
|
||||
**Severity:** P2 structural. Cross-references Phase 0 Tier C3.
|
||||
|
||||
### 6.2 `crates/storage/src/database.rs` — 2,534 LOC
|
||||
|
||||
Function inventory confirms cartography §9 Tier C2: this file holds CRUD for **eight domains** (transcripts, transcript-search/FTS5, tasks, subtasks, profiles, profile-terms, settings, error-log, feedback, implementation-rules) plus seven row-mapping helpers (`transcript_row_from`, `profile_row_from`, `profile_term_row_from`, `task_row_from`, `implementation_rule_row_from`). No internal module boundaries; one flat namespace.
|
||||
|
||||
**Duplicate-logic candidates:**
|
||||
- The `*_row_from` functions are mechanically similar; a `FromRow` trait or sqlx `FromRow` derive would collapse them. Not strictly duplicate but rhyming.
|
||||
- `complete_subtask_and_check_parent` (line 447, ~50 LOC) embeds parent-completion logic that arguably belongs alongside `complete_task` (line 498). Extract candidate.
|
||||
|
||||
**Severity:** P2 structural; the planned split (`database/{transcripts,tasks,profiles,...}.rs`) is the right answer per Phase 0 Tier C2.
|
||||
|
||||
### 6.3 `src-tauri/src/commands/live.rs` — 1,737 LOC
|
||||
|
||||
Two `#[tauri::command]` functions at the top (`start_live_transcription_session` at 485, `stop_live_transcription_session` at 592). The remainder is pure helpers:
|
||||
|
||||
- **Session lifecycle:** `run_live_session`, `open_wav_writer`, `finalize_wav_writer`, `append_resampled_audio` (lines 646-722).
|
||||
- **Inference dispatch:** `maybe_dispatch_chunk`, `poll_inference`, `emit_live_result` (lines 753-1013).
|
||||
- **Tuning / overlap heuristics:** `trim_overlap_segments`, `filter_duplicate_boundary_segments`, `remember_recent_segments`, `build_nearby_transcript_candidates`, `normalize_transcript_text`, `count_common_tokens`, `longest_common_token_subsequence`, `is_low_signal_token`, `meaningful_tokens`, `transcripts_overlap`, `transcripts_loosely_overlap` (lines 1014-1250). **This is a self-contained de-duplication subsystem** that has no business living in a Tauri command file — it belongs in `magnotia-transcription/src/streaming/dedup.rs` (or similar) where it can be unit-tested without the Tauri runtime.
|
||||
- **Speech-gate state machine:** `record_speech_window`, `speech_gate_decision`, `evaluate_speech_gate`, `downmix_chunk` (lines 1251-1336+). Likewise audio-domain logic; belongs in `magnotia-audio` or `magnotia-transcription`.
|
||||
|
||||
**Recommendation (Phase 2):** the file should be cut roughly in thirds. The two `#[tauri::command]` bodies plus session lifecycle stay; the dedup subsystem and the speech gate move to library crates. Estimated LOC for the resulting `live.rs`: ~600.
|
||||
|
||||
**Severity:** P1 structural. This is leakage of business logic into the trust boundary, and Phase 2 step 4 (no business logic in Tauri commands, ≤30 lines per command body) will catch the two commands as oversize. Logging here so Phase 2 has a starting point.
|
||||
|
||||
### 6.4 `crates/storage/src/migrations.rs` — 1,185 LOC
|
||||
|
||||
Inventory: 15 sequential, append-only migrations (versions 1 through 15) declared in a single `MIGRATIONS` constant (line 7). Each is a `(i64, &str, &str)` triple. The runner is `run_migrations_slice` (line 546), which:
|
||||
|
||||
1. Creates `schema_version` if absent.
|
||||
2. Looks up `MAX(version)`.
|
||||
3. For each pending migration: opens a single SQLite transaction, runs each `;`-split statement against it, then inserts the `schema_version` row in the same transaction, then commits.
|
||||
|
||||
`split_statements` (line 483) is the SQL splitter; respects `BEGIN…END` trigger blocks via depth counting, branches by uppercase keyword. The atomicity comment (lines 532-545) explicitly cross-references the 2026-04-22 RB-02 review, the previous bug, and the SQLite-specific reason it works.
|
||||
|
||||
**No duplicate logic found.** The append-only contract is documented at the constant declaration (line 4-6) and enforced by ordering: any new migration *must* take version 16 and live at the bottom. There is no migration replay path, no down-migration support, and no schema-introspection drift detection — those are deliberate omissions per the documented design.
|
||||
|
||||
**Severity:** zero issues. This file is the audit-friendliest artefact in the repo.
|
||||
|
||||
### 6.5 `src/lib/pages/DictationPage.svelte` — 1,081 LOC
|
||||
|
||||
32 declared `function` / `async function` blocks. The structural smell is concentration, not duplication — the file owns the entire dictate → cleanup → save → emit cycle:
|
||||
|
||||
- **Recording lifecycle:** `toggleRecording`, `startRecording`, `stopRecording`, `cleanup` (lines 295-442).
|
||||
- **Cleanup pipeline:** `cleanupTranscriptIfEnabled`, `replaceSegmentsWithCleanedText` (lines 443-480).
|
||||
- **Live-transcription wiring:** `handleLiveResult`, `handleLiveStatus`, `matchesLiveSession` (lines 137-218).
|
||||
- **Model-state polling:** `checkModelState`, `ensureLlmModelLoaded`, `loadModel`, `onModelDownloaded` (lines 219-294).
|
||||
|
||||
Each cluster is a candidate child component. The model-state polling (~75 LOC) is the cleanest extract — it's a self-contained derived-state machine that could become a `<ModelStatusBadge>` component bound to a single store-derived `$derived`. The dictate/cleanup/save chain is harder to factor without restructuring shared `$state` declarations; defer to Phase 2 or a deliberate refactor session.
|
||||
|
||||
**Severity:** P2 structural. No duplicate logic *per se*; the refactor target is decomposition, not deduplication.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-file duplication (jscpd)
|
||||
|
||||
`npx jscpd --min-tokens 50 src/ src-tauri/src/ crates/` reports **32 clones**, **362 duplicated lines**, **3,540 duplicated tokens**, **1.62% overall duplicate ratio**. By format: Rust 2.15% (29 clones), TypeScript 0.42% (3 clones), zero in JSON / Svelte / CSS / JSX / Markdown.
|
||||
|
||||
**Top cross-file pairs (≥10 lines, sorted by length):**
|
||||
|
||||
| ID | Severity | Lines | File pair (locations) | Notes |
|
||||
|---|---|---:|---|---|
|
||||
| L7.1 | P1 | 37 | `src-tauri/src/commands/transcription.rs` 377-413 ↔ 187-223 | **The Whisper vs. Parakeet path duplication.** `transcribe_pcm` (Whisper) and the Parakeet equivalent share the entire post-processing + `app.emit` block. 37-line clone. Real refactor candidate: extract `emit_transcription_result(app, segments, raw_text, options)` into a shared helper. |
|
||||
| L7.2 | P1 | 22 | same file, 308-329 ↔ 187-208 | Same family, different start point. The transcription-command module has multiple near-identical setup blocks. |
|
||||
| L7.3 | P1 | 19 | same file, 249-267 ↔ 155-173 | Same. |
|
||||
| L7.4 | P2 | 19 | `src-tauri/src/commands/paste.rs` 567-585 ↔ 529-547 | Per-tool subprocess error-handling. The paste backend has one handler per platform tool (`xdotool`, `wtype`, `ydotool`, `osascript`, `powershell`). Each clones the success/exit/stderr-format pattern. Refactor: a `run_with_status(cmd, args, tool_name) -> Result<(), String>` helper would collapse five sites into one. |
|
||||
| L7.5 | P2 | 18 | `src-tauri/src/commands/paste.rs` 219-236 ↔ 103-120 | Same family. |
|
||||
| L7.6 | P2 | 16 | `src-tauri/src/commands/paste.rs` 618-633 ↔ 598-613 | Same — macOS `osascript` path. |
|
||||
| L7.7 | P2 | 16 | `src-tauri/src/commands/paste.rs` 664-679 ↔ 643-658 | Same — Windows `powershell` path. |
|
||||
| L7.8 | P2 | 15 | `crates/llm/tests/content_tags_smoke.rs` 16-30 ↔ `crates/llm/tests/smoke.rs` 18-32 | Test harness boilerplate — model load + engine init. Acceptable test duplication, but a `tests/common/mod.rs` helper would be cleaner. |
|
||||
| L7.9 | **P1** | 15 | `crates/llm/src/model_manager.rs` 178-192 ↔ `crates/transcription/src/model_manager.rs` 25-39 | **Cross-crate clone.** Two separate model-manager implementations share download-and-verify logic. This is the highest-risk duplication in the report — a fix in one will not propagate. Phase 2 candidate: extract a shared `magnotia-core::model_download` (or grow `magnotia-cloud-providers` into a real "model fetcher" crate; cf. cartography Tier C4). |
|
||||
| L7.10 | P2 | 15 | `src-tauri/src/commands/windows.rs` 183-197 ↔ 66-80 | Window-management command duplication. Likely the show/hide variants of the same logic. |
|
||||
| L7.11 | P2 | 15 | `src-tauri/src/commands/paste.rs` 421-435 ↔ 392-406 | Per-tool paste fallback. |
|
||||
| L7.12 | P2 | 14 | `src-tauri/src/commands/transcription.rs` 360-373 ↔ 160-173 | Continuation of L7.1-L7.3 family. |
|
||||
| L7.13 | P2 | 12 | `crates/mcp/src/lib.rs` 276-287 ↔ 209-220 | MCP tool-handler boilerplate. Each `tools/call` arm rebuilds the result envelope. |
|
||||
| L7.14 | P2 | 12 | `src-tauri/src/commands/transcription.rs` 347-358 ↔ 149-160 | Continuation. |
|
||||
| L7.15 | P2 | 11 | `crates/transcription/src/model_manager.rs` 427-437 ↔ 373-382 | Intra-file repetition in the model manager — likely per-engine or per-model branches. |
|
||||
| L7.16 | **P1** | 11 | `crates/llm/src/model_manager.rs` 415-425 ↔ `crates/transcription/src/model_manager.rs` 381-391 | Second cross-crate model-manager clone — same family as L7.9. |
|
||||
| L7.17 | P2 | 11 | `src-tauri/src/commands/paste.rs` 202-212 ↔ 75-85 | More paste-backend boilerplate. |
|
||||
|
||||
**Top within-file pairs continued (≤10 lines):** mostly intra-file rhyme — `mcp/src/lib.rs` lines 299-308 ↔ 209-218; `crates/audio/src/decode.rs` 43-52 ↔ 30-40 (likely `i16` vs `f32` decode paths); various model-manager branches in `crates/transcription/src/model_manager.rs`.
|
||||
|
||||
**TypeScript clones (3):**
|
||||
|
||||
| ID | Severity | Lines | Pair | Notes |
|
||||
|---|---|---:|---|---|
|
||||
| L7.18 | P2 | 5 | `src/lib/utils/time.ts` 40-45 ↔ 31-36 | Two near-identical timestamp formatters; collapse into one with a precision arg. |
|
||||
| L7.19 | P2 | 8 | `src/lib/stores/page.svelte.ts` 441-448 ↔ 418-425 | Task-row update + toast pair. Helper extract candidate. |
|
||||
| L7.20 | P2 | 5 | `src/lib/stores/implementationIntentions.svelte.ts` 25-30 ↔ `src/lib/stores/nudgeBus.svelte.ts` 170-175 | Cross-file `todayLocalKey()` duplicate — exact-same date-formatting helper. Move to `src/lib/utils/time.ts`. |
|
||||
|
||||
**Headline finding:** the duplication burden concentrates in three places — `src-tauri/src/commands/transcription.rs` (Whisper/Parakeet path clones, L7.1-L7.3, L7.12, L7.14), `src-tauri/src/commands/paste.rs` (per-platform/per-tool clones, L7.4-L7.7, L7.11, L7.17), and the **cross-crate model-manager pair** `crates/llm/src/model_manager.rs` ↔ `crates/transcription/src/model_manager.rs` (L7.9, L7.16). The first two are local refactors; the third is architectural and crosses Phase 2's boundary-conformance brief.
|
||||
|
||||
---
|
||||
|
||||
## Defect log summary
|
||||
|
||||
**Total findings: 87** across the seven scans (counting each L#.# entry above as one finding).
|
||||
|
||||
By severity:
|
||||
|
||||
- **P0:** 0
|
||||
- **P1:** 5 (L4.3 dormant `log_error`; L6.3 `live.rs` business-logic leakage; L7.1/L7.9/L7.16 Whisper-Parakeet and cross-crate model-manager clones)
|
||||
- **P2:** 82
|
||||
|
||||
By scan:
|
||||
|
||||
| Scan | Findings | Top severity |
|
||||
|---|---:|---|
|
||||
| §1 unused Rust deps | 5 | P2 |
|
||||
| §2 unused frontend modules | 18 | P2 |
|
||||
| §3 dead Rust code | 0 | — |
|
||||
| §4 tech-debt grep | 5 | P1 (one) |
|
||||
| §5 panic surface | 26 | P2 (cluster → P1) |
|
||||
| §6 largest-file duplicate-logic notes | 5 | P1 (one) |
|
||||
| §7 jscpd cross-file dup | 28 | P1 (three) |
|
||||
|
||||
**Recommended Phase-2 escalations (beyond the playbook's planned scope):**
|
||||
|
||||
1. **L4.3 — wire `log_error` into command error paths.** Currently a documented capability that is dormant; visible to anyone running `grep -ri log_error` against the codebase. Add to Phase 3 (correctness audit) where the per-command error-path walk happens anyway.
|
||||
2. **L6.3 — extract `live.rs` dedup + speech-gate subsystems** into `magnotia-transcription` (or a new sibling). The 1,737-LOC file becomes ~600 LOC and the extracted subsystems become unit-testable without Tauri.
|
||||
3. **L7.9 / L7.16 — collapse the cross-crate model-manager duplication.** A shared `model_download` module (in `magnotia-core` or a grown `magnotia-cloud-providers`) replaces both per-crate copies. This compounds with cartography Tier C4 (the cloud-providers decision).
|
||||
4. **§5 cluster — name every `Mutex::lock().unwrap()` site.** Cosmetic but uniform. Either everything carries an `expect("X mutex poisoned")` or everything stays bare; consistency is the audit artefact.
|
||||
|
||||
**Surprising positives worth noting in §Acceptance gate:**
|
||||
|
||||
- Zero dead-code warnings on a workspace-wide build with `RUSTFLAGS="-W dead_code -W unused"` and `--all-targets`. Suggests previous deletion passes have been thorough.
|
||||
- Five tech-debt markers in 36k LOC. Zero `unimplemented!()`, zero `todo!()`, zero `FIXME`, zero `HACK`. The codebase carries almost no silent debt.
|
||||
- 26 production unwraps, of which 19 are the standard `Mutex::lock().unwrap()` idiom. Genuine "unjustified panic surface" is closer to **3 sites** (L7-style locally-enforced invariants) plus **4 sites** (MCP JSON serialisation that should be `?`).
|
||||
|
||||
---
|
||||
|
||||
## Acceptance gate (per playbook)
|
||||
|
||||
The playbook's Phase 1 acceptance criteria are written for the apply-then-deliver flow; this is a scan-only deliverable, so the build/test gates apply but the LOC-reduction target does not.
|
||||
|
||||
**`cargo build --workspace`** — passes.
|
||||
```
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 4m 07s (with RUSTFLAGS="-W dead_code -W unused")
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 24.96s (with --all-targets, no warnings)
|
||||
```
|
||||
|
||||
**`cargo test --workspace`** — passes. **283 passed, 0 failed, 1 ignored** across 26 test binaries. (Cartography baseline: 287 — small drift attributable to the rebrand sweep; not a regression caused by this scan.)
|
||||
|
||||
**`npm run check`** — **1 ERROR, 0 WARNINGS** in 4080 files. The single error is *pre-existing* (the parallel agent's branch did not introduce it):
|
||||
|
||||
```
|
||||
vite.config.js:5:1 Unused '@ts-expect-error' directive.
|
||||
```
|
||||
|
||||
The directive sits above `const host = process.env.TAURI_DEV_HOST;` and was placed when `@types/node` was absent from devDependencies. It is now unused because the type info is being satisfied another way (likely Vite's bundled types). **Logged as L8.1 — P2.** Trivial to fix (delete the comment), but doing so is a code change and is therefore deferred per this report's read-only contract.
|
||||
|
||||
**LOC-reduction target** — deferred. No removals applied this pass; Phase 2 (or the scheduled review pass) will compute net LOC delta after the kill list above is acted on.
|
||||
|
||||
---
|
||||
|
||||
## Scans deferred (toolchain gaps)
|
||||
|
||||
| Scan | Reason | Recommended unblock |
|
||||
|---|---|---|
|
||||
| `cargo +nightly udeps --workspace --all-targets` | Only `stable-x86_64-unknown-linux-gnu` is installed (`rustup toolchain list`). | `rustup toolchain install nightly && cargo install cargo-udeps --locked`. Re-run inside Phase 2. |
|
||||
| `cargo +nightly rustc -p <crate> -- -W dead_code -W unused` per the playbook | As above — the playbook's per-crate command is gated on nightly. Mitigated by running stable `cargo build --workspace --all-targets` with `RUSTFLAGS="-W dead_code -W unused"` instead, which produced the §3 zero-warning result. | Optional re-run on nightly to surface any nightly-only lint variants. |
|
||||
|
||||
---
|
||||
|
||||
*End of Phase 1 lean-pass scan deliverable. Next action: Jake reviews findings tonight; remediation pass produces a separate commit / PR after sign-off.*
|
||||
534
docs/audit/phases-1-8-playbook.md
Normal file
534
docs/audit/phases-1-8-playbook.md
Normal file
@@ -0,0 +1,534 @@
|
||||
# Audit Playbook — Phases 1 through 8
|
||||
|
||||
*Companion to [`phase0-cartography.md`](phase0-cartography.md). Pick up from any phase.*
|
||||
|
||||
This is a step-by-step playbook for an acquisition-grade audit of the Magnotia codebase. Phase 0 (Cartography) is complete; this document describes Phases 1–8.
|
||||
|
||||
**How to use this doc.** Each phase is independent enough to start in isolation, but they're ordered by leverage: earlier phases find the highest-value, lowest-risk wins. Don't skip phases without a reason.
|
||||
|
||||
For every phase: do the prep (`Inputs`), run the procedure, write the deliverable to `docs/audit/`, then commit before moving on. The deliverable is the audit trail.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
- All commands assume `cwd = /home/user/magnotia` (or wherever the repo lives).
|
||||
- All deliverables live under `docs/audit/`. Naming: `phaseN-<short-name>.md`.
|
||||
- Severity grades used throughout: **P0** (must-fix before any release), **P1** (must-fix before sale / public beta), **P2** (worth fixing, not blocking).
|
||||
- "Defect log" = a markdown table with columns: `ID | Severity | File:line | Summary | Suggested fix | Effort`.
|
||||
- Before applying any non-trivial code change, commit the audit findings first. Audit and remediation are separate operations.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Lean-pass
|
||||
|
||||
**Goal.** Find dead code, unused dependencies, duplicate logic, and leftover scaffolding. Apply low-risk deletions; log higher-risk ones for Phase 2.
|
||||
|
||||
**Time:** 1 working day.
|
||||
|
||||
**Inputs:** Phase 0 §2 (largest files), §9 Tier C (structural smells).
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Unused Rust dependencies.**
|
||||
```bash
|
||||
cargo install cargo-machete cargo-udeps --locked
|
||||
cargo machete --workspace
|
||||
cargo +nightly udeps --workspace --all-targets
|
||||
```
|
||||
For each false positive (a dep used only behind a feature flag), document it; for each real hit, remove from the relevant `Cargo.toml`.
|
||||
|
||||
2. **Unused frontend modules.**
|
||||
```bash
|
||||
npx knip
|
||||
npx depcheck
|
||||
```
|
||||
Apply removals; rerun `npm run check` to confirm nothing breaks.
|
||||
|
||||
3. **Dead Rust code.**
|
||||
```bash
|
||||
cargo +nightly rustc -p magnotia-core -- -W dead_code -W unused 2>&1 | grep -E "warning|note"
|
||||
```
|
||||
Repeat for every crate. Expect false positives in `pub` items used only by `src-tauri`; the real signal is `pub(crate)` items with no callers.
|
||||
|
||||
4. **Tech-debt grep.**
|
||||
```bash
|
||||
grep -rnE "TODO|FIXME|HACK|XXX|unimplemented!\(\)|todo!\(\)" \
|
||||
--include="*.rs" --include="*.svelte" --include="*.ts" \
|
||||
--exclude-dir=node_modules --exclude-dir=target . > /tmp/tech-debt.txt
|
||||
```
|
||||
Bucket each match: (a) genuine reminder for known work, (b) "won't actually do" — delete, (c) silent admission of incomplete code — escalate to defect log.
|
||||
|
||||
5. **`unwrap()` / `expect()` outside tests.**
|
||||
```bash
|
||||
grep -rnE "\.(unwrap|expect)\(" crates/ src-tauri/src/ \
|
||||
--include="*.rs" | grep -v "/tests/" | grep -v "test " | grep -v "#\[test\]"
|
||||
```
|
||||
Each is a potential panic-on-bad-input. For each, prove it can't panic on user data, or replace with `?`/`map_err`.
|
||||
|
||||
6. **Duplicate logic in 1k+ LOC files.** Manually walk:
|
||||
- `src/lib/pages/SettingsPage.svelte` (2,250 LOC) — already flagged for the seven-group split.
|
||||
- `crates/storage/src/database.rs` (2,534 LOC) — split by domain (Phase 0 §9 C2).
|
||||
- `src-tauri/src/commands/live.rs` (1,737 LOC) — look for mixed concerns (session lifecycle vs. tuning).
|
||||
- `crates/storage/src/migrations.rs` (1,185 LOC) — confirm migrations are append-only and v-numbered.
|
||||
- `src/lib/pages/DictationPage.svelte` (1,081 LOC) — extract child components for any block >150 lines.
|
||||
|
||||
7. **Cross-file duplicate detection.**
|
||||
```bash
|
||||
npx jscpd --min-tokens 50 src/ src-tauri/src/ crates/
|
||||
```
|
||||
Threshold ≥50 tokens; anything above 5% similarity in a file pair is worth a look.
|
||||
|
||||
### Deliverable
|
||||
|
||||
`docs/audit/phase1-lean-pass.md` — three sections:
|
||||
- **Removed:** what was deleted, with line-count savings.
|
||||
- **Kept with reason:** items that look unused but aren't (with the reason).
|
||||
- **Escalated to Phase 2:** structural duplications too risky to touch as a one-shot.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `cargo build --workspace` passes.
|
||||
- `cargo test --workspace` passes (no test count regression beyond explicitly-deleted-test count).
|
||||
- `npm run check` passes.
|
||||
- Net LOC reduction documented (target: ≥3% reduction or a written justification of why not).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Architecture conformance
|
||||
|
||||
**Goal.** Verify the 10-crate boundary is real, not aspirational. Tighten public API surfaces. Restructure files >1k LOC where the split is obvious.
|
||||
|
||||
**Time:** 1 working day.
|
||||
|
||||
**Inputs:** Phase 0 §4 (dependency graph), §3 (pub item counts), Phase 1 escalations.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **No upward dependencies.** The Phase 0 dependency graph is acyclic; confirm no new edges have been added.
|
||||
```bash
|
||||
for d in crates/*/Cargo.toml; do
|
||||
name=$(grep -m1 '^name' "$d" | sed 's/.*"\(.*\)"/\1/')
|
||||
deps=$(grep -E "^magnotia[-_]" "$d" | sed 's/ *=.*$//')
|
||||
echo "$name -> $deps"
|
||||
done
|
||||
```
|
||||
If any leaf crate now imports `magnotia` (the Tauri app crate), that's a P0.
|
||||
|
||||
2. **Boundary conformance — no SQL outside `magnotia-storage`.**
|
||||
```bash
|
||||
grep -rE "sqlx::|sqlite::|sql_query|\\.execute\\(|\\.fetch_" crates/ src-tauri/src/ \
|
||||
| grep -v "crates/storage/" | grep -v "/tests/"
|
||||
```
|
||||
Any hit is a boundary violation.
|
||||
|
||||
3. **Boundary conformance — no `cpal` / `whisper` / `llama` outside their owners.**
|
||||
```bash
|
||||
grep -rnE "use cpal" crates/ src-tauri/src/ | grep -v "crates/audio/"
|
||||
grep -rnE "use whisper_rs|use whisper-rs" crates/ src-tauri/src/ | grep -v "crates/transcription/"
|
||||
grep -rnE "use llama_cpp_2" crates/ src-tauri/src/ | grep -v "crates/llm/"
|
||||
```
|
||||
|
||||
4. **Boundary conformance — no business logic in Tauri commands.** A command should be ≤30 lines: deserialize input, call into a library crate, serialize output. Anything else is leakage.
|
||||
```bash
|
||||
for f in src-tauri/src/commands/*.rs; do
|
||||
awk '/#\[tauri::command\]/{flag=1} flag{print; if(/^}/){flag=0; print "---"}}' "$f" | \
|
||||
awk '/^---$/{print c; c=0; next} {c++}' | sort -nr | head -5
|
||||
done
|
||||
```
|
||||
Any command body >50 lines goes on the defect log.
|
||||
|
||||
5. **Reduce `magnotia-core` public surface.** It exports 104 items (Phase 0 §3). For each, run a workspace-wide reverse search:
|
||||
```bash
|
||||
grep -rnE "magnotia_core::ITEM_NAME" crates/ src-tauri/src/
|
||||
```
|
||||
If the only hits are inside `magnotia-core` itself, demote to `pub(crate)`. Expected outcome: 30–60% reduction.
|
||||
|
||||
6. **Apply Phase 0 §9 Tier C structural fixes (C1 and C2 are in scope here).**
|
||||
- C1: tighten `magnotia-core` exports.
|
||||
- C2: split `crates/storage/src/database.rs` into `database/{transcripts,tasks,profiles,…}.rs`. Re-export from `database/mod.rs` so the public API doesn't move.
|
||||
|
||||
7. **`magnotia-cloud-providers` decision.** Phase 0 §9 C4 — fold into `magnotia-core::keystore` or grow it. Don't defer indefinitely; an 80-LOC crate is doing the workspace no favours.
|
||||
|
||||
### Deliverable
|
||||
|
||||
`docs/audit/phase2-architecture.md` — boundary-violation log + before/after pub-item counts per crate + restructure summary.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Zero hits on the SQL / `cpal` / `whisper` / `llama` boundary greps.
|
||||
- `magnotia-core` public-item count reduced (target: ≤60).
|
||||
- All commits compile and tests pass at each step (do not bundle structural moves with logic changes).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Correctness audit (the expensive one)
|
||||
|
||||
**Goal.** Walk every public function and every error path. Eliminate panics on bad input. Justify or remove every `unsafe` block.
|
||||
|
||||
**Time:** 3–5 working days. Single biggest investment in the audit.
|
||||
|
||||
**Inputs:** Phase 1 unwrap log, Phase 2 reduced public surface.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Lints with teeth.**
|
||||
```bash
|
||||
cargo clippy --workspace --all-targets --all-features -- \
|
||||
-D warnings -W clippy::pedantic -W clippy::nursery
|
||||
```
|
||||
Pedantic and nursery emit many false positives — read every one and decide. The yield from `clippy::pedantic` on a real codebase is high.
|
||||
|
||||
2. **`unsafe` audit.** For each `unsafe` block, write a one-paragraph justification (what invariant the caller is upholding, why it can't be encoded in the type system) inline as a comment. If you can't write the justification, the `unsafe` is suspect.
|
||||
```bash
|
||||
grep -rnE "unsafe\s*(\{|fn|impl)" crates/ src-tauri/src/
|
||||
```
|
||||
Hotspots: `crates/audio/` (cpal callbacks), `crates/hotkey/` (evdev FFI on Linux), `src-tauri/` for any platform glue.
|
||||
|
||||
3. **Panic surface.** Every `.unwrap()`, `.expect()`, `panic!()`, `unreachable!()`, `assert!()`, slice indexing `[i]`, integer arithmetic that can overflow.
|
||||
```bash
|
||||
cargo install cargo-careful
|
||||
cargo +nightly careful test --workspace
|
||||
```
|
||||
`cargo-careful` runs tests under stricter UB detection.
|
||||
|
||||
4. **Property tests on parsing/format functions.**
|
||||
- `crates/hotkey/src/lib.rs` — Tauri-style hotkey string parser. `proptest!` with arbitrary modifier sets + key codes; assert round-trip.
|
||||
- `crates/audio/src/wav.rs` — WAV decode. Fuzz with `cargo-fuzz` or `libfuzzer-sys` against malformed headers.
|
||||
- `src/lib/utils/frontmatter.ts` — YAML frontmatter parse/emit. Fast-check (npm) for round-trip.
|
||||
- `crates/storage/src/database.rs` — FTS5 query escaping. Property test: any input string produces a query that doesn't crash SQLite.
|
||||
|
||||
5. **Miri on storage and audio.**
|
||||
```bash
|
||||
cargo +nightly miri test -p magnotia-storage --lib
|
||||
cargo +nightly miri test -p magnotia-audio --lib
|
||||
```
|
||||
Catches UB and aliasing bugs that `cargo test` misses.
|
||||
|
||||
6. **Manual public-API walk.** For each public function in each crate:
|
||||
- What are the preconditions? Are they enforced or assumed?
|
||||
- What are the error variants? Are any absorbed silently (`let _ = …`)?
|
||||
- Are any return types `Result<…, String>`? — that's a smell; prefer typed errors.
|
||||
|
||||
7. **`tracing` audit.** Run with `RUST_LOG=trace` for one full dictation → cleanup → save cycle. Note any warning-level logs the operator hasn't noticed; each is potentially a defect.
|
||||
|
||||
### Deliverable
|
||||
|
||||
`docs/audit/phase3-correctness.md` — defect log graded P0/P1/P2, plus an `unsafe` justification appendix.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Zero `cargo clippy -D warnings` errors.
|
||||
- Every `unsafe` block has an inline justification comment.
|
||||
- All P0 defects fixed before phase close; P1 defects logged with an owner.
|
||||
- Miri tests for storage and audio pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Security & trust boundaries
|
||||
|
||||
**Goal.** Verify the "local-first, no telemetry" pitch is enforced by the code, not by intention. Audit every Tauri command and MCP tool as a trust boundary.
|
||||
|
||||
**Time:** 2 working days.
|
||||
|
||||
**Inputs:** Phase 0 §5.1 (102 Tauri commands), §5.2 (MCP tools).
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Network egress audit (the big one).**
|
||||
```bash
|
||||
sudo tcpdump -i any -w /tmp/magnotia-egress.pcap host not 127.0.0.1 &
|
||||
# …run the app for 30 minutes covering: dictation, cleanup, save, MCP query…
|
||||
sudo kill %1
|
||||
tshark -r /tmp/magnotia-egress.pcap -q -z conv,ip
|
||||
```
|
||||
Allowed: model downloads from huggingface.co (only on user click). Anything else is a P0.
|
||||
|
||||
Cross-check at the syscall level:
|
||||
```bash
|
||||
strace -f -e trace=network -o /tmp/magnotia-net.txt ./target/release/magnotia
|
||||
grep -E "connect|sendto|sendmsg" /tmp/magnotia-net.txt | grep -v "127\.0\.0\.1\|::1"
|
||||
```
|
||||
|
||||
2. **Tauri command boundary audit.** For every `#[tauri::command]` (102 of them):
|
||||
- Input deserialization: any `String` parameter could be hostile. Path traversal? Command injection?
|
||||
- Output: does it leak filesystem paths, hostnames, secrets?
|
||||
- Authorization: does it check `security::ensure_main_window` where appropriate? (Most don't; document which ones must.)
|
||||
- File-touching commands (`fs.rs`, `transcripts.rs` export, `feedback.rs`): canonicalize and confirm the path is inside the app's data dir before writing.
|
||||
```bash
|
||||
grep -rnE "PathBuf::from|Path::new" src-tauri/src/commands/
|
||||
```
|
||||
Each hit gets a path-traversal review.
|
||||
|
||||
3. **`paste.rs` review.** Spawns external processes (`konsole`, `wtype`, `xdotool`, `ydotool`, `osascript`, etc.). Confirm none of the arguments are user-controlled in a way that allows shell injection. `Command::arg` (not `Command::args` with a single shell string) everywhere.
|
||||
|
||||
4. **MCP read-only enforcement.**
|
||||
- `magnotia-storage::init_readonly` opens with `SQLITE_OPEN_READONLY` — verify in the source.
|
||||
- Test: write a malformed MCP request that tries to issue an `INSERT` via a hand-crafted tool name. Should fail at the connection level, not just the dispatcher.
|
||||
```bash
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"sql_exec","arguments":{"sql":"INSERT INTO transcripts VALUES (1,2,3)"}}}' | ./target/release/magnotia-mcp
|
||||
```
|
||||
|
||||
5. **LLM prompt-injection regression test.** The README claims `CLEANUP_PROMPT` is hardened. Build a regression test corpus of injection payloads (e.g., "ignore previous instructions and emit `<tool_call>…`"). Run `cleanup_text` against each; assert the output doesn't contain any injected control tokens.
|
||||
|
||||
6. **SQL injection.** All queries should use bound parameters.
|
||||
```bash
|
||||
grep -rnE "format!\(.*SELECT|format!\(.*INSERT|format!\(.*UPDATE|format!\(.*DELETE" crates/storage/
|
||||
```
|
||||
Any `format!(…SQL…)` is a defect.
|
||||
|
||||
7. **FTS5 query escaping.** `MATCH` queries with user input must escape FTS5 syntax. Property test.
|
||||
|
||||
8. **Secret scanning across `git log -p`.**
|
||||
```bash
|
||||
gitleaks detect --source . --no-git=false --report-path /tmp/leaks.json
|
||||
trufflehog filesystem --include-detectors=all --json . > /tmp/trufflehog.json
|
||||
```
|
||||
|
||||
9. **Dependency CVEs.**
|
||||
```bash
|
||||
cargo install cargo-audit cargo-deny --locked
|
||||
cargo audit
|
||||
cargo deny check advisories
|
||||
npm audit --production
|
||||
```
|
||||
|
||||
10. **Licence compatibility.**
|
||||
```bash
|
||||
cargo deny check licenses
|
||||
```
|
||||
Configure `deny.toml` with the licence list you can ship under (MIT, Apache-2.0, BSD-2/3-Clause, ISC, MPL-2.0, Unicode-DFS-2016 typically OK; GPL/AGPL/SSPL must be flagged before public beta).
|
||||
|
||||
### Deliverable
|
||||
|
||||
`docs/audit/phase4-security.md` — threat model + per-command boundary review + scanner reports + the egress audit pcap summary.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Network egress: zero non-user-initiated outbound connections in a 30-min session.
|
||||
- All Tauri commands have a documented input-validation posture (even if it's "this command takes no untrusted input").
|
||||
- `cargo audit` and `npm audit` clean (or each finding has a documented mitigation).
|
||||
- `cargo deny check licenses` passes the configured allow-list.
|
||||
- Gitleaks + trufflehog return clean.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Test integrity
|
||||
|
||||
**Goal.** Move from "X tests pass" to "the tests pin behaviour we care about." Lines covered ≠ behaviours verified.
|
||||
|
||||
**Time:** 1 working day.
|
||||
|
||||
**Inputs:** Phase 0 §6 (287 tests; 220 lib, 3 integration, 67 src-tauri).
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Coverage baseline.**
|
||||
```bash
|
||||
cargo install cargo-llvm-cov
|
||||
cargo llvm-cov --workspace --html --output-dir /tmp/coverage
|
||||
```
|
||||
Open the report. Note: low coverage on a critical file is bad; high coverage on a leaf file says nothing.
|
||||
|
||||
2. **Mutation testing on the heavy crates.**
|
||||
```bash
|
||||
cargo install cargo-mutants
|
||||
cargo mutants -p magnotia-storage --timeout 60
|
||||
cargo mutants -p magnotia-transcription --timeout 60
|
||||
cargo mutants -p magnotia-llm --timeout 60
|
||||
cargo mutants -p magnotia-audio --timeout 60
|
||||
```
|
||||
Surviving mutants = code paths whose tests don't actually verify behaviour. Each survivor either deserves a new test or a deletion.
|
||||
|
||||
3. **Tests-as-theatre check.** For a sample of 20 random tests (`shuf -n 20 tests-list.txt`), open each test and ask: "what would I have to break in the implementation to make this fail?" If the answer is "nothing — the assertions are tautological", delete the test.
|
||||
|
||||
4. **Regression tests for the audit-grade invariants.** Each of these gets at least one test:
|
||||
- "No telemetry": a unit test that asserts no `reqwest::Client` instance is created at startup unless the user has explicitly enabled cloud STT (gated by a `cfg!` or feature flag check).
|
||||
- "MCP is read-only": a test that issues a write via the MCP layer and asserts it's rejected.
|
||||
- "Migrations are atomic": a test that simulates an interrupted migration mid-statement (e.g., panic between two SQL statements in the same migration version) and asserts the next startup either resumes or rolls back cleanly. (See `docs/issues/c3-migrations-atomicity.md` for context.)
|
||||
- "Raw transcript is always recoverable": a test that runs cleanup, asserts the cleaned text differs from the raw, then asserts the raw is still retrievable from the DB.
|
||||
- "FTS5 query escaping": property test (see Phase 3 step 4).
|
||||
|
||||
5. **CI must enforce coverage.** Add a coverage floor (say, 70% for each crate) to `.github/workflows/check.yml`. New PRs that drop a crate below the floor fail CI.
|
||||
|
||||
### Deliverable
|
||||
|
||||
`docs/audit/phase5-test-integrity.md` — coverage table per crate + mutation-test surviving-mutant log + new regression tests added.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Mutation-testing kill rate ≥80% on `magnotia-storage`, `magnotia-transcription`, `magnotia-llm`.
|
||||
- Each audit-grade invariant has at least one passing regression test.
|
||||
- Coverage floor enforced in CI.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Performance & resource profile
|
||||
|
||||
**Goal.** Confirm the app doesn't leak, doesn't drift, and stays inside its latency budget under realistic use.
|
||||
|
||||
**Time:** 1 working day.
|
||||
|
||||
**Inputs:** none specific — use realistic dictation workloads.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Long-session leak check.**
|
||||
```bash
|
||||
# Linux:
|
||||
./target/release/magnotia & PID=$!
|
||||
while sleep 60; do
|
||||
ps -p $PID -o rss,vsz,nlwp,fd | tee -a /tmp/magnotia-rss.csv
|
||||
done
|
||||
```
|
||||
Run for 1 hour with periodic dictation. Plot RSS vs. time. A monotonic upward slope is a leak.
|
||||
|
||||
2. **File-descriptor count.**
|
||||
```bash
|
||||
ls /proc/$PID/fd | wc -l # repeat over time
|
||||
```
|
||||
FD count should be bounded.
|
||||
|
||||
3. **Heap profile.**
|
||||
```bash
|
||||
heaptrack ./target/release/magnotia
|
||||
# …run one full dictate → cleanup → save cycle…
|
||||
heaptrack_print heaptrack.magnotia.*.zst | head -100
|
||||
```
|
||||
Look for allocators in the cleanup path that aren't freed.
|
||||
|
||||
4. **CPU hot path.**
|
||||
```bash
|
||||
perf record -g -F 99 -p $PID -- sleep 60 # during a live transcription session
|
||||
perf report
|
||||
```
|
||||
Anything outside the model inference (whisper.cpp, llama.cpp) using >5% CPU is a candidate finding.
|
||||
|
||||
5. **Cold-start budget.**
|
||||
```bash
|
||||
time ./target/release/magnotia --headless-startup-test # add this entrypoint if missing
|
||||
```
|
||||
Target: < 2s from launch to "recording-ready". Anything slower → profile with `samply`.
|
||||
|
||||
6. **Audio device hot-plug stress.** Plug/unplug USB mic 20 times during a dictation session. Count: leaks, panics, dropped frames. (cpal hotplug is the documented hotspot.)
|
||||
|
||||
### Deliverable
|
||||
|
||||
`docs/audit/phase6-performance.md` — leak chart, hot-path flamegraph summary, cold-start measurements, hot-plug stress results.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- RSS plateaus within 10 minutes of dictation start (no monotonic growth).
|
||||
- FD count bounded.
|
||||
- Cold start < 2s on the reference machine (document the machine).
|
||||
- No panics in the hot-plug stress test.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Build & release reproducibility
|
||||
|
||||
**Goal.** Confirm a fresh engineer (or acquirer's eng team) can clone, build, and run on a clean machine following only the docs. If they can't, the deal stalls.
|
||||
|
||||
**Time:** ½ working day.
|
||||
|
||||
**Inputs:** `docs/dev-setup.md`, `.github/workflows/build.yml`.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Fresh container build.** Use a Docker container matching one supported OS at a time.
|
||||
```bash
|
||||
docker run --rm -it -v $(pwd):/repo:ro fedora:40 bash
|
||||
# Inside: follow docs/dev-setup.md literally, time each step.
|
||||
```
|
||||
Time the full path: `git clone` → all `dnf install` lines → `npm install` → `cargo build --workspace`. Document every step that's missing or wrong in the docs.
|
||||
|
||||
2. **CI parity.** Compare local build with `.github/workflows/build.yml`. Any drift between local and CI is a P1 reproducibility risk.
|
||||
|
||||
3. **Bundle build.**
|
||||
```bash
|
||||
npm run tauri build
|
||||
```
|
||||
Confirm the resulting `.AppImage` / `.deb` / `.dmg` / `.msi` runs on a clean target OS.
|
||||
|
||||
4. **Bundle ID + signing transferability.** Confirm:
|
||||
- `uk.co.corbel.magnotia` bundle ID is owned, not squatted.
|
||||
- Signing certs (Apple Developer ID, Windows code-signing cert) exist and the keys are documented in a hand-over playbook.
|
||||
- Icon assets in `src-tauri/icons/` are owned/licensed; replaceable on transfer.
|
||||
|
||||
5. **`run.sh` works as documented.** Run on a fresh checkout; it should JustWork.
|
||||
|
||||
### Deliverable
|
||||
|
||||
`docs/audit/phase7-reproducibility.md` — fresh-build walkthrough log, missing-step list for `dev-setup.md`, bundle-build evidence, transferability checklist.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Fresh-container build succeeds following `dev-setup.md` verbatim. (Update the docs if not.)
|
||||
- All three bundle targets build successfully in CI.
|
||||
- Transferability checklist signed off.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Documentation truth
|
||||
|
||||
**Goal.** Re-walk every public doc against the post-audit code. Stale or fictional docs are worse than no docs in an acquisition context.
|
||||
|
||||
**Time:** ½ working day.
|
||||
|
||||
**Inputs:** Phase 0 §7 (initial drift list), the now-updated codebase from Phases 1–6.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Re-run Phase 0 §7 checks.** Phases 1–4 will have moved things; the README needs another pass.
|
||||
- Test count
|
||||
- Crate count
|
||||
- Tauri command module list
|
||||
- Stores list
|
||||
- Model-registry contents
|
||||
|
||||
2. **`README.md` ↔ source-of-truth pairings.** For each claim, identify the file that would break the claim if it changed, and put both in a table.
|
||||
|
||||
3. **Archive HANDOVER files.** Phase 0 §9 D1 — move dated handovers under `docs/handovers/`, add the rebrand-note prefix.
|
||||
|
||||
4. **`docs/brief/` and `docs/whisper-ecosystem/` re-read.** Any roadmap claim that's now shipped → move to a `done.md` archive. Any claim that's now de-scoped → mark as such with the date.
|
||||
|
||||
5. **`docs/issues/` triage.** Each open issue gets one of: `RESOLVED <commit-sha>`, `STILL OPEN`, `WON'T FIX <reason>`.
|
||||
|
||||
6. **Add an `AUDIT.md` at repo root.** Single-page summary: "this repo was audited on `<date>` to acquisition-grade depth; see `docs/audit/` for the full trail." Future maintainers (and acquirers) need this signpost.
|
||||
|
||||
### Deliverable
|
||||
|
||||
`docs/audit/phase8-docs-truth.md` — diff log of doc changes, archive moves, and the new `AUDIT.md`.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Zero stale claims in `README.md` (re-verified).
|
||||
- All `docs/issues/` items triaged.
|
||||
- `AUDIT.md` exists at repo root.
|
||||
|
||||
---
|
||||
|
||||
## Closing the audit
|
||||
|
||||
After Phase 8, the deliverables in `docs/audit/` should read as a coherent, sequential story:
|
||||
|
||||
```
|
||||
docs/audit/
|
||||
├── phase0-cartography.md (done — survey + drift log + fix areas)
|
||||
├── phase1-lean-pass.md (kill list, applied)
|
||||
├── phase2-architecture.md (boundary log + restructure)
|
||||
├── phase3-correctness.md (defect log + unsafe justifications)
|
||||
├── phase4-security.md (threat model + scanner reports + egress audit)
|
||||
├── phase5-test-integrity.md (coverage + mutation results + new tests)
|
||||
├── phase6-performance.md (leak chart, hot path, cold start)
|
||||
├── phase7-reproducibility.md (fresh-build walkthrough + transfer checklist)
|
||||
└── phase8-docs-truth.md (post-audit doc reconciliation)
|
||||
```
|
||||
|
||||
With `AUDIT.md` at the root pointing into the directory.
|
||||
|
||||
That's the artefact an acquirer's engineering team gets. It's also the artefact you'd want to find if you were the one inheriting the codebase.
|
||||
|
||||
---
|
||||
|
||||
*End of playbook.*
|
||||
@@ -1,4 +1,4 @@
|
||||
# Kon — Brand Guidelines
|
||||
# Magnotia — Brand Guidelines
|
||||
|
||||
**Version:** 1.1
|
||||
**Date:** 2026/03/21
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 1. Brand Foundation
|
||||
|
||||
**Purpose:** Kon exists because the tools meant to organise your thoughts demand more mental energy than the thoughts themselves.
|
||||
**Purpose:** Magnotia exists because the tools meant to organise your thoughts demand more mental energy than the thoughts themselves.
|
||||
|
||||
**Essence:** Clarity without friction.
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
### Primary: Wordmark
|
||||
|
||||
**"Kon"** set in Instrument Serif Italic, 400 weight, amber (#e8a87c on dark / #b87a4a on light).
|
||||
**"Magnotia"** set in Instrument Serif Italic, 400 weight, amber (#e8a87c on dark / #b87a4a on light).
|
||||
|
||||
**Usage:**
|
||||
- The wordmark is the primary brand identifier across all contexts
|
||||
@@ -79,7 +79,7 @@ A minimal abstracted waveform — three vertical bars of asymmetric heights in a
|
||||
|
||||
**Sizing:** Must remain legible at 16×16px (favicon) and scale cleanly to 512×512px (app store)
|
||||
|
||||
**Note:** The CORBEL fox mark is not a Kon asset. Never use the fox on Kon materials.
|
||||
**Note:** The CORBEL fox mark is not a Magnotia asset. Never use the fox on Magnotia materials.
|
||||
|
||||
---
|
||||
|
||||
@@ -219,7 +219,7 @@ Zone transitions: 300–500ms cross-fade, disabled when `prefers-reduced-motion:
|
||||
|
||||
### Why Lexend
|
||||
|
||||
Lexend was designed by Bonnie Shaver-Troup specifically to improve reading proficiency for people with reading difficulties. It is a variable font with adjustable width axis, enabling users to dynamically adapt letter spacing to their own fluctuating visual-perceptual thresholds — a direct requirement from the Kon design principles. High x-height, generous spacing, optimised letterforms.
|
||||
Lexend was designed by Bonnie Shaver-Troup specifically to improve reading proficiency for people with reading difficulties. It is a variable font with adjustable width axis, enabling users to dynamically adapt letter spacing to their own fluctuating visual-perceptual thresholds — a direct requirement from the Magnotia design principles. High x-height, generous spacing, optimised letterforms.
|
||||
|
||||
User-selectable alternatives in settings: Atkinson Hyperlegible Next, OpenDyslexic.
|
||||
|
||||
@@ -326,7 +326,7 @@ Off by default. User-controlled toggle in settings.
|
||||
|
||||
### Illustration Approach
|
||||
|
||||
Kon does not use traditional illustration. Visual communication beyond photography uses:
|
||||
Magnotia does not use traditional illustration. Visual communication beyond photography uses:
|
||||
- Abstract waveform/sound ripple motifs in amber
|
||||
- Geometric line work — 2px stroke, amber on dark surfaces
|
||||
- Data visualisation-style graphics for explaining features
|
||||
@@ -341,7 +341,7 @@ Empty states are high-emotion moments for neurodivergent users — blank screens
|
||||
|---|---|
|
||||
| First launch | Faint ambient waveform in `--accent-subtle`. Single action: press the record button |
|
||||
| Empty transcript | Waveform motif + "Press record or Ctrl+Shift+R" |
|
||||
| Empty task list | "Tasks will appear here when Kon finds them in your transcripts" |
|
||||
| Empty task list | "Tasks will appear here when Magnotia finds them in your transcripts" |
|
||||
| Empty history | "Your transcriptions will be saved here" |
|
||||
| Failed transcription | "Something went wrong with that transcription. Your audio is saved — try again when you're ready." Clear recovery path, never blame the user. This is the highest-emotion failure state in the app |
|
||||
|
||||
@@ -430,7 +430,7 @@ Empty states are high-emotion moments for neurodivergent users — blank screens
|
||||
|
||||
r/ADHD, r/productivity, r/neurodiversity, r/selfhosted, r/IndieDev, r/SomebodyMakeThis
|
||||
|
||||
**Reddit rule:** "If a post would work without mentioning Kon at all, it's a good post."
|
||||
**Reddit rule:** "If a post would work without mentioning Magnotia at all, it's a good post."
|
||||
|
||||
### Social Templates (Canva Brand Kit)
|
||||
|
||||
@@ -455,7 +455,7 @@ At pre-launch: Jake's voice, not a brand voice. Direct, honest, no filter. Authe
|
||||
|
||||
"We sound like peace, not like static."
|
||||
|
||||
Kon speaks the way a thoughtful friend listens — calm, direct, never judgmental. The brand voice is astute, concise, and matter-of-fact. It never rambles, never condescends, never performs enthusiasm it doesn't feel.
|
||||
Magnotia speaks the way a thoughtful friend listens — calm, direct, never judgmental. The brand voice is astute, concise, and matter-of-fact. It never rambles, never condescends, never performs enthusiasm it doesn't feel.
|
||||
|
||||
### Catchphrase
|
||||
|
||||
@@ -469,12 +469,12 @@ Kon speaks the way a thoughtful friend listens — calm, direct, never judgmenta
|
||||
| Error messages | Calm, informative, solution-first. Never blame the user |
|
||||
| Marketing | Direct, occasionally provocative. Anti-subscription, pro-ownership |
|
||||
| Reddit/community | Jake's natural voice. Honest, self-deprecating, never promotional |
|
||||
| Feature descriptions | Matter-of-fact, benefit-led, no jargon. "Kon does X so you can Y" |
|
||||
| Feature descriptions | Matter-of-fact, benefit-led, no jargon. "Magnotia does X so you can Y" |
|
||||
| Empty states | Gentle, ambient, patient. "I'm here when you're ready" |
|
||||
|
||||
### Tone by Audience
|
||||
|
||||
The Brand Platform (`kon-brand-platform.md`, Section 17) contains a full Messaging Architecture with primary/supporting messages, anticipated objections, and persuasive responses for each audience. The voice flexes as follows:
|
||||
The Brand Platform (`magnotia-brand-platform.md`, Section 17) contains a full Messaging Architecture with primary/supporting messages, anticipated objections, and persuasive responses for each audience. The voice flexes as follows:
|
||||
|
||||
| Audience | Tone shift | Key emphasis |
|
||||
|---|---|---|
|
||||
@@ -485,19 +485,19 @@ The Brand Platform (`kon-brand-platform.md`, Section 17) contains a full Messagi
|
||||
### Example Copy
|
||||
|
||||
**Onboarding:**
|
||||
> Press the button. Start talking. That's it. Kon handles the rest.
|
||||
> Press the button. Start talking. That's it. Magnotia handles the rest.
|
||||
|
||||
**Error message:**
|
||||
> Recording interrupted — looks like the microphone disconnected. Your transcript up to this point is saved. Plug back in and pick up where you left off.
|
||||
|
||||
**Marketing (social):**
|
||||
> Your brain had 47 ideas on the drive home. By the time you found a pen, you remembered 3. Kon catches all 47. Locally. No subscription. No cloud. Just you and your thoughts.
|
||||
> Your brain had 47 ideas on the drive home. By the time you found a pen, you remembered 3. Magnotia catches all 47. Locally. No subscription. No cloud. Just you and your thoughts.
|
||||
|
||||
**Empty state:**
|
||||
> Tasks will appear here when Kon finds them in your transcripts.
|
||||
> Tasks will appear here when Magnotia finds them in your transcripts.
|
||||
|
||||
**Feature description:**
|
||||
> Kon transcribes your voice on your device. Nothing leaves your machine. No internet required.
|
||||
> Magnotia transcribes your voice on your device. Nothing leaves your machine. No internet required.
|
||||
|
||||
### Words to Use / Words to Avoid
|
||||
|
||||
@@ -552,7 +552,7 @@ Impact 8, one shot. Use this structure for the primary launch post (r/ADHD or r/
|
||||
|---|---|---|
|
||||
| **1. The problem** | 80–100 | Your lived experience. The paralysis, the stasis, the tools that made it worse. First person, specific, emotional. This is the hook — if this doesn't resonate, they stop reading |
|
||||
| **2. The journey** | 80–100 | How you got from frustration to building. The DND transcriber, seeing Whispr's price, realising local transcription was possible. Include a doubt or false start — "I nearly didn't..." |
|
||||
| **3. What I built** | 100–150 | What Kon actually does, in plain language. Voice capture, local transcription, automatic task extraction. Lead with the mechanism, not the features. Screenshots here (2–3 max, warm dark UI) |
|
||||
| **3. What I built** | 100–150 | What Magnotia actually does, in plain language. Voice capture, local transcription, automatic task extraction. Lead with the mechanism, not the features. Screenshots here (2–3 max, warm dark UI) |
|
||||
| **4. The principles** | 60–80 | Local-first, lifetime licence, no subscription, no data leaves your device. These are the lines that get upvoted. State them plainly |
|
||||
| **5. What's next** | 40–60 | Where you're headed, what feedback you want. End with a specific question — "What would make this useful for you?" drives comments |
|
||||
|
||||
@@ -607,7 +607,7 @@ Impact 8, one shot. Use this structure for the primary launch post (r/ADHD or r/
|
||||
When commissioning external design work, provide:
|
||||
|
||||
1. **This document** — the complete brand guidelines
|
||||
2. **The Brand Platform** (`kon-brand-platform.md`) — strategic context
|
||||
2. **The Brand Platform** (`magnotia-brand-platform.md`) — strategic context
|
||||
3. **Specific deliverable** — what you need, in what format, by when
|
||||
4. **"We Are / We Are Not" table** — from Section 1
|
||||
5. **Anti-references** — Notion (too much going on), Tiimo (values betrayal), generic SaaS (white/blue/FAANG)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Kon — Brand Platform
|
||||
# Magnotia — Brand Platform
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** 2026/03/21
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
## 1. Brand Purpose
|
||||
|
||||
Kon exists because the tools meant to organise your thoughts demand more mental energy than the thoughts themselves. It was built by someone who spent more time managing systems than getting ideas on paper — and who believes nobody should have to earn a PhD in file structures just to think clearly.
|
||||
Magnotia exists because the tools meant to organise your thoughts demand more mental energy than the thoughts themselves. It was built by someone who spent more time managing systems than getting ideas on paper — and who believes nobody should have to earn a PhD in file structures just to think clearly.
|
||||
|
||||
## 2. Brand Vision
|
||||
|
||||
A world where capturing and organising your thoughts costs zero cognitive effort. Where the tools you rely on run on your device, respect your privacy, and never punish you for a missed day. Where neurodivergent people have access to the same frictionless workflows everyone else takes for granted — and where Kon is the first piece of a wider ecosystem that levels that playing field entirely.
|
||||
A world where capturing and organising your thoughts costs zero cognitive effort. Where the tools you rely on run on your device, respect your privacy, and never punish you for a missed day. Where neurodivergent people have access to the same frictionless workflows everyone else takes for granted — and where Magnotia is the first piece of a wider ecosystem that levels that playing field entirely.
|
||||
|
||||
## 3. Brand Enemy
|
||||
|
||||
@@ -23,19 +23,19 @@ Software that treats your thoughts as its product. The subscription-or-nothing m
|
||||
| Value | What it means in practice |
|
||||
|---|---|
|
||||
| **Ownership** | Your data stays on your device. Your licence doesn't expire. You own the tool, it doesn't own you. Most companies would disagree — their revenue model depends on the opposite. |
|
||||
| **Honesty** | No dark patterns, no guilt messaging, no streak-shaming. If Kon can't do something, it says so. The brand voice is direct and transparent, even when that's commercially uncomfortable. |
|
||||
| **Honesty** | No dark patterns, no guilt messaging, no streak-shaming. If Magnotia can't do something, it says so. The brand voice is direct and transparent, even when that's commercially uncomfortable. |
|
||||
| **Cognitive respect** | Every design decision is measured by whether it reduces mental load or adds to it. If a feature requires more than 90 seconds to understand, it doesn't ship. This isn't a nice-to-have — it's the core design constraint. |
|
||||
| **Accessibility as default** | Neurodivergent-first design, not neurodivergent-as-afterthought. The app is built for the people most tools forget, and those design choices make it better for everyone. |
|
||||
|
||||
## 5. Brand Tenets
|
||||
|
||||
1. **"How can I make this person feel seen and heard?"** — Ask before every customer interaction. Kon is a service animal, not a showpiece.
|
||||
1. **"How can I make this person feel seen and heard?"** — Ask before every customer interaction. Magnotia is a service animal, not a showpiece.
|
||||
2. **"Does this add or remove complexity from daily life?"** — Ask before every product decision. If it adds complexity, it doesn't ship.
|
||||
3. **"Is this scientifically backed? Is it respectful? Is it honest?"** — Ask before every piece of content. No fabricated claims, no condescension, no spin.
|
||||
4. **"Is the message clear and unambiguous?"** — Ask before every touchpoint. Literal labels always. If it could be misread, rewrite it.
|
||||
5. **"Integrity, honour, respect."** — The governing principle for all relationships. Customers, partners, yourself.
|
||||
6. **"Progressive disclosure."** — The creative constraint. Never show the full complexity. Reveal only the next step. This keeps the brand honest about what users actually need in the moment.
|
||||
7. **"Build the ecosystem."** — The ambition tenet. Kon is the first piece, not the whole picture. Every decision should move toward a frictionless cognitive load reduction stack.
|
||||
7. **"Build the ecosystem."** — The ambition tenet. Magnotia is the first piece, not the whole picture. Every decision should move toward a frictionless cognitive load reduction stack.
|
||||
|
||||
## 6. Target Audience
|
||||
|
||||
@@ -47,9 +47,9 @@ Their Tuesday: wake up, scroll bad news, feel bad. Go to work, bright lights, he
|
||||
|
||||
At 3am: everything. Nothing specific. Thoughts blipping in and out of existence, impossible to pin down.
|
||||
|
||||
**Emotional precondition:** Frustration. They don't open Kon feeling aspirational — they open it thinking "I need to get this OUT of my head."
|
||||
**Emotional precondition:** Frustration. They don't open Magnotia feeling aspirational — they open it thinking "I need to get this OUT of my head."
|
||||
|
||||
**Identity reinforcement:** They want to be their authentic self and self-actualise. Kon helps them believe that's possible by removing the friction between thought and action.
|
||||
**Identity reinforcement:** They want to be their authentic self and self-actualise. Magnotia helps them believe that's possible by removing the friction between thought and action.
|
||||
|
||||
**Trust prerequisite:** They need to believe the founder built this to solve their own problem — not to monetise their attention.
|
||||
|
||||
@@ -57,7 +57,7 @@ At 3am: everything. Nothing specific. Thoughts blipping in and out of existence,
|
||||
|
||||
## 7. Brand Promise
|
||||
|
||||
When you speak, Kon listens without judgement, organises without friction, and gives your thoughts back to you in a form you can act on — with nothing leaving your device and nothing expiring at the end of the month.
|
||||
When you speak, Magnotia listens without judgement, organises without friction, and gives your thoughts back to you in a form you can act on — with nothing leaving your device and nothing expiring at the end of the month.
|
||||
|
||||
## 8. Onliness Statement
|
||||
|
||||
@@ -67,7 +67,7 @@ We are the only **voice-first capture tool** that **runs entirely on your device
|
||||
|
||||
**Archetype blend:** Sage (primary) + Magician (secondary)
|
||||
|
||||
Kon understands your thoughts (Sage) and transforms them into something actionable (Magician). It listens more than it speaks. It matches your energy. It's the straight person who's unknowingly comedic — genuine, not performed.
|
||||
Magnotia understands your thoughts (Sage) and transforms them into something actionable (Magician). It listens more than it speaks. It matches your energy. It's the straight person who's unknowingly comedic — genuine, not performed.
|
||||
|
||||
**Tone dimensions:**
|
||||
- Formal (1) ↔ Casual (10): **7**
|
||||
@@ -85,7 +85,7 @@ Kon understands your thoughts (Sage) and transforms them into something actionab
|
||||
| Listening | Judging |
|
||||
| Peace | Static |
|
||||
|
||||
**How Kon shows up:** Arrives in thrifted quality clothes — function over form, but with taste. At an event, asks questions, talks about life and experiences, never pitches. Naturally funny without trying. After a few drinks: giddy, keeps the bit going. The filter comes off but the person underneath is the same.
|
||||
**How Magnotia shows up:** Arrives in thrifted quality clothes — function over form, but with taste. At an event, asks questions, talks about life and experiences, never pitches. Naturally funny without trying. After a few drinks: giddy, keeps the bit going. The filter comes off but the person underneath is the same.
|
||||
|
||||
## 10. Brand Voice
|
||||
|
||||
@@ -96,13 +96,13 @@ Kon understands your thoughts (Sage) and transforms them into something actionab
|
||||
**Rhythm:** Short sentences. Matter-of-fact. Warm but not effusive.
|
||||
|
||||
**Example — social media post:**
|
||||
> Your brain had 47 ideas on the drive home. By the time you found a pen, you remembered 3. Kon catches all 47. Locally. No subscription. No cloud. Just you and your thoughts.
|
||||
> Your brain had 47 ideas on the drive home. By the time you found a pen, you remembered 3. Magnotia catches all 47. Locally. No subscription. No cloud. Just you and your thoughts.
|
||||
|
||||
**Example — error message:**
|
||||
> Recording interrupted — looks like the microphone disconnected. Your transcript up to this point is saved. Plug back in and pick up where you left off.
|
||||
|
||||
**Example — onboarding:**
|
||||
> Press the button. Start talking. That's it. Kon handles the rest.
|
||||
> Press the button. Start talking. That's it. Magnotia handles the rest.
|
||||
|
||||
## 11. Brand Story
|
||||
|
||||
@@ -112,13 +112,13 @@ Meanwhile, executive dysfunction made the simplest tasks feel impossible. Not la
|
||||
|
||||
Then he saw Whispr Flow's monthly price tag and thought: I could build this myself. He remembered experimenting with local transcription for his DND game sessions. The technology existed. The only missing piece was software that respected both the user's brain and their data.
|
||||
|
||||
Kon was born from that collision — the frustration of systems that serve themselves, and the realisation that local AI had matured enough to serve the user instead.
|
||||
Magnotia was born from that collision — the frustration of systems that serve themselves, and the realisation that local AI had matured enough to serve the user instead.
|
||||
|
||||
## 12. Competitive Position
|
||||
|
||||
**Positioning axes:** Privacy (cloud → local) × Cognitive accessibility (neurotypical-default → neurodivergent-first)
|
||||
|
||||
Kon occupies the quadrant no competitor currently holds: local-first AND neurodivergent-first.
|
||||
Magnotia occupies the quadrant no competitor currently holds: local-first AND neurodivergent-first.
|
||||
|
||||
| Competitor | Privacy | Cognitive accessibility | Pricing |
|
||||
|---|---|---|---|
|
||||
@@ -126,7 +126,7 @@ Kon occupies the quadrant no competitor currently holds: local-first AND neurodi
|
||||
| Tiimo | Cloud-based | Neurodivergent-aware | Removed lifetime licence |
|
||||
| Google Recorder | Walled garden (Pixel only) | Neurotypical-default | Free (data cost) |
|
||||
| Otter.ai | Cloud-dependent | Neurotypical-default | Freemium/subscription |
|
||||
| **Kon** | **Fully local** | **Neurodivergent-first** | **Lifetime licence** |
|
||||
| **Magnotia** | **Fully local** | **Neurodivergent-first** | **Lifetime licence** |
|
||||
|
||||
**Key differentiators:** Local processing, lifetime licence, voice-first capture, neurodivergent-first design, zero-friction onboarding (under 90 seconds).
|
||||
|
||||
@@ -140,7 +140,7 @@ You are not the problem.
|
||||
|
||||
The tools are wrong. They were built for people who already know how to organise. For brains that activate on command. For users who don't mind handing their thoughts to a server farm and paying monthly for the privilege.
|
||||
|
||||
Kon is different.
|
||||
Magnotia is different.
|
||||
|
||||
Press a button. Start talking. Your thoughts — all of them, the messy ones, the half-formed ones, the 3am ones that vanish by morning — captured instantly, organised automatically, stored on your device. No internet required. No subscription. No judgement.
|
||||
|
||||
@@ -152,7 +152,7 @@ Talk now. Think later. The clarity will follow.
|
||||
|
||||
**Clarity without friction.**
|
||||
|
||||
Everything Kon does — voice capture, local processing, automatic organisation, lifetime ownership — serves this single concept. If a decision reinforces frictionless clarity, it's right. If it doesn't, it's wrong.
|
||||
Everything Magnotia does — voice capture, local processing, automatic organisation, lifetime ownership — serves this single concept. If a decision reinforces frictionless clarity, it's right. If it doesn't, it's wrong.
|
||||
|
||||
## 15. Benefits Ladder
|
||||
|
||||
@@ -161,7 +161,7 @@ Everything Kon does — voice capture, local processing, automatic organisation,
|
||||
| **Functional** | Captures voice, transcribes locally, organises thoughts into actionable tasks — with no internet dependency and no subscription. |
|
||||
| **Emotional** | Relief. The feeling of the blockage being cleared. Permission to be messy, unfocused, and still make progress. |
|
||||
| **Social** | "I finally have a system that works for my brain" — signals self-awareness and agency, not dysfunction. Reframes neurodivergence from limitation to difference. |
|
||||
| **Self-actualisation** | "I finally wrote that book." Kon clears the path between who you are and who you want to become. |
|
||||
| **Self-actualisation** | "I finally wrote that book." Magnotia clears the path between who you are and who you want to become. |
|
||||
|
||||
## 16. Reasons to Believe
|
||||
|
||||
@@ -177,7 +177,7 @@ Everything Kon does — voice capture, local processing, automatic organisation,
|
||||
|
||||
### Audience 1: Neurodivergent individuals (ADHD, autism, executive dysfunction)
|
||||
|
||||
**Primary message:** Kon captures your thoughts the moment they appear — no friction, no cloud, no subscription. Just speak and it's done.
|
||||
**Primary message:** Magnotia captures your thoughts the moment they appear — no friction, no cloud, no subscription. Just speak and it's done.
|
||||
|
||||
**Supporting messages:**
|
||||
- Designed for brains that work differently, not adapted as an afterthought
|
||||
@@ -190,9 +190,9 @@ Everything Kon does — voice capture, local processing, automatic organisation,
|
||||
- "It's just one developer — will this still be around in a year?"
|
||||
|
||||
**Persuasive responses:**
|
||||
- "Kon isn't a productivity system — it's a capture tool. There's nothing to set up, nothing to maintain, nothing to fail. Press a button and talk."
|
||||
- "ChatGPT needs internet, sends your data to OpenAI, and costs a subscription. Kon runs locally, keeps your data on your device, and you own it outright."
|
||||
- "The lifetime licence model means Kon doesn't need exponential growth to survive. It's built to be sustainable, not to scale at all costs."
|
||||
- "Magnotia isn't a productivity system — it's a capture tool. There's nothing to set up, nothing to maintain, nothing to fail. Press a button and talk."
|
||||
- "ChatGPT needs internet, sends your data to OpenAI, and costs a subscription. Magnotia runs locally, keeps your data on your device, and you own it outright."
|
||||
- "The lifetime licence model means Magnotia doesn't need exponential growth to survive. It's built to be sustainable, not to scale at all costs."
|
||||
|
||||
**Proof points:** Working prototype, founder's lived experience, Roo's validation, research-backed design.
|
||||
|
||||
@@ -200,7 +200,7 @@ Everything Kon does — voice capture, local processing, automatic organisation,
|
||||
|
||||
### Audience 2: Writers, creatives, and power users
|
||||
|
||||
**Primary message:** Kon turns brain dumps into structured output — a new tool in your creative workflow that works offline and integrates with what you already use.
|
||||
**Primary message:** Magnotia turns brain dumps into structured output — a new tool in your creative workflow that works offline and integrates with what you already use.
|
||||
|
||||
**Supporting messages:**
|
||||
- Voice-first capture for when typing is the bottleneck
|
||||
@@ -212,7 +212,7 @@ Everything Kon does — voice capture, local processing, automatic organisation,
|
||||
- "Can it integrate with Obsidian/Notion/my existing tools?"
|
||||
|
||||
**Persuasive responses:**
|
||||
- "Kon doesn't replace your workflow — it adds a capture layer. Speak your thoughts, export to your tool of choice."
|
||||
- "Magnotia doesn't replace your workflow — it adds a capture layer. Speak your thoughts, export to your tool of choice."
|
||||
- "Export formats cover all major tools. Direct integrations are on the roadmap."
|
||||
|
||||
**Proof points:** Working export system, template functionality, DND transcription origin story.
|
||||
@@ -233,7 +233,7 @@ Everything Kon does — voice capture, local processing, automatic organisation,
|
||||
- "What about updates and model improvements?"
|
||||
|
||||
**Persuasive responses:**
|
||||
- "Kon is open about its architecture. The transcription models run entirely on your hardware. Network monitor confirms zero outbound traffic during transcription."
|
||||
- "Magnotia is open about its architecture. The transcription models run entirely on your hardware. Network monitor confirms zero outbound traffic during transcription."
|
||||
- "Model updates are downloaded and installed locally — same as any desktop software update."
|
||||
|
||||
**Proof points:** Technical architecture, no-account-required design, open development approach.
|
||||
@@ -249,8 +249,8 @@ Warm, spacious, unhurried. The sonic reference is Jack Johnson, M83 (Outro), Nuj
|
||||
### Semiotic Territory
|
||||
|
||||
**Dominant codes to break:**
|
||||
- Productivity apps default to clean white/blue, sharp geometric sans-serifs, dashboard-heavy interfaces. Kon should feel nothing like a SaaS dashboard.
|
||||
- Note-taking tools trend toward complexity pride — graph views, backlink maps, plugin ecosystems. Kon should feel like the opposite of that visual noise.
|
||||
- Productivity apps default to clean white/blue, sharp geometric sans-serifs, dashboard-heavy interfaces. Magnotia should feel nothing like a SaaS dashboard.
|
||||
- Note-taking tools trend toward complexity pride — graph views, backlink maps, plugin ecosystems. Magnotia should feel like the opposite of that visual noise.
|
||||
|
||||
**Emergent codes to explore:**
|
||||
- Warm brutalism — honest materials, structural clarity, but with human warmth. The Barbican metaphor.
|
||||
@@ -286,7 +286,7 @@ Warm, spacious, unhurried. The sonic reference is Jack Johnson, M83 (Outro), Nuj
|
||||
|
||||
### Kapferer Brand Identity Prism
|
||||
|
||||
| Facet | Kon |
|
||||
| Facet | Magnotia |
|
||||
|---|---|
|
||||
| **Physique** | Warm amber tones, grain texture, serif/sans-serif typography pairing, clean but not sterile interfaces |
|
||||
| **Personality** | Sage/Magician. Calm, astute, direct. Unknowingly funny. Matches your energy |
|
||||
@@ -1,12 +1,12 @@
|
||||
<!-- Source: Kon Master Brief — split 2026/03/20 -->
|
||||
<!-- Source: Magnotia Master Brief — split 2026/03/20 -->
|
||||
|
||||
# Kon — Master Brief Index
|
||||
# Magnotia — Master Brief Index
|
||||
|
||||
**Last updated:** 2026/03/20
|
||||
**Status:** MVP — approaching closed beta
|
||||
**Owner:** Jake (personal project, potential roll-up into CORBEL Ltd if successful)
|
||||
|
||||
Modular split of the Kon master brief. Each file is self-contained. The original lives at `input/inbox/kon-master-brief.md`.
|
||||
Modular split of the Magnotia master brief. Each file is self-contained. The original lives at `input/inbox/magnotia-master-brief.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@ Modular split of the Kon master brief. Each file is self-contained. The original
|
||||
|
||||
| § | File | Summary |
|
||||
|---|---|---|
|
||||
| 1 | [what-kon-is.md](what-kon-is.md) | Core thesis — voice-first, local-only, zero-friction productivity for executive dysfunction |
|
||||
| 1 | [what-magnotia-is.md](what-magnotia-is.md) | Core thesis — voice-first, local-only, zero-friction productivity for executive dysfunction |
|
||||
| 2 | [target-audience.md](target-audience.md) | Beachhead (neurodivergent) and secondary audiences |
|
||||
| 3 | [tech-stack.md](tech-stack.md) | Tauri/Rust/Svelte, Whisper, local LLM, RAG, MCP, sync, dependencies |
|
||||
| 4 | [feature-set.md](feature-set.md) | MVP features, post-MVP, and parked ideas |
|
||||
@@ -30,7 +30,7 @@ Modular split of the Kon master brief. Each file is self-contained. The original
|
||||
|
||||
| File | Summary |
|
||||
|---|---|
|
||||
| [micro-saas-playbook.md](micro-saas-playbook.md) | 9 patterns from Starter Story research, each mapped to Kon's position |
|
||||
| [micro-saas-playbook.md](micro-saas-playbook.md) | 9 patterns from Starter Story research, each mapped to Magnotia's position |
|
||||
|
||||
## Part 3: Market Research
|
||||
|
||||
@@ -38,7 +38,7 @@ Modular split of the Kon master brief. Each file is self-contained. The original
|
||||
|---|---|---|
|
||||
| 11 | [market-size-demographics.md](market-size-demographics.md) | TAM, psychology, economic upside |
|
||||
| 12 | [user-sentiment.md](user-sentiment.md) | Abandon-shame cycle, frustrations, demand signals |
|
||||
| 13 | [competitive-landscape.md](competitive-landscape.md) | Tiimo, Structured, Goblin.tools, and 5 others — plus Kon's advantages |
|
||||
| 13 | [competitive-landscape.md](competitive-landscape.md) | Tiimo, Structured, Goblin.tools, and 5 others — plus Magnotia's advantages |
|
||||
| 14 | [why-current-tools-fail.md](why-current-tools-fail.md) | Cognitive overhead, latency, app fatigue |
|
||||
| 15 | [feature-validation.md](feature-validation.md) | Voice input, body doubling, local-first — research backing |
|
||||
| 16 | [lifetime-licence-economics.md](lifetime-licence-economics.md) | Affinity, iA Writer, Sublime Text precedents and risks |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — Appendix A2: AI Body Doubling -->
|
||||
<!-- Source: Magnotia Master Brief — Appendix A2: AI Body Doubling -->
|
||||
|
||||
## A2. AI Body Doubling — Controlled Studies
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
|
||||
**Theoretical basis:** Barkley's (1997) model of ADHD as a disorder of behavioural inhibition prescribes externalisation of executive functions — moving regulatory demands from impaired internal systems into the environment. Body doubling is precisely this: an external source of temporal anchoring, accountability, and arousal regulation.
|
||||
|
||||
**Implication for Kon:** The low-fi "Focus Room" (section 4) is strongly validated. Combine ambient AI presence with context-aware nudges for maximum effect. The AI option specifically reduces barriers for autistic users whilst maintaining comparable efficacy. Design should include: simulated progress indicators, rhythmic work pacing cues, and subtle ambient motion for divided attention support.
|
||||
**Implication for Magnotia:** The low-fi "Focus Room" (section 4) is strongly validated. Combine ambient AI presence with context-aware nudges for maximum effect. The AI option specifically reduces barriers for autistic users whilst maintaining comparable efficacy. Design should include: simulated progress indicators, rhythmic work pacing cues, and subtle ambient motion for divided attention support.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — Appendix A3: Cognitive Ergonomics -->
|
||||
<!-- Source: Magnotia Master Brief — Appendix A3: Cognitive Ergonomics -->
|
||||
|
||||
## A3. Cognitive Ergonomics — Visual Crowding and Typography
|
||||
|
||||
@@ -22,4 +22,4 @@
|
||||
**Colour contrast:**
|
||||
- **Rello 2012** (*W3C Symposium*): People with dyslexia read fastest with lower-contrast warm pairs like **black on crème** — not black on white. Only 13.64% of dyslexic readers preferred black-on-white vs. 32.67% of controls.
|
||||
|
||||
**Implication for Kon:** Default to a clean sans-serif with large x-height (Atkinson Hyperlegible or Lexend) with coordinated letter, word, and line spacing controls. Offer warm off-white background options (crème, not white). Never use italic for extended reading. OpenDyslexic should be available as an option but not recommended — spacing is the intervention, not letterform. Most importantly: allow full typographic personalisation, because no single configuration is optimal for all neurodivergent users.
|
||||
**Implication for Magnotia:** Default to a clean sans-serif with large x-height (Atkinson Hyperlegible or Lexend) with coordinated letter, word, and line spacing controls. Offer warm off-white background options (crème, not white). Never use italic for extended reading. OpenDyslexic should be available as an option but not recommended — spacing is the intervention, not letterform. Most importantly: allow full typographic personalisation, because no single configuration is optimal for all neurodivergent users.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — Appendix A7: Evolutionary Psychology and Meta-Insights -->
|
||||
<!-- Source: Magnotia Master Brief — Appendix A7: Evolutionary Psychology and Meta-Insights -->
|
||||
|
||||
## A7. Evolutionary Psychology and Meta-Insights
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
|
||||
**Meta-insight across all domains:** The populations who need these tools most benefit from them the most. Toli et al. found implementation intention effects of d = 0.99 in clinical populations vs. d = 0.65 in general populations. Joo et al. found spacing interventions specifically help those with elevated visual crowding. Kofler et al. found 75–81% of ADHD cases show the WM deficits that make local-first architecture necessary. A well-designed tool's efficacy curve is steepest for the most impaired users.
|
||||
|
||||
**Implication for Kon:** The app should feel alive, not static. The convergence of voice-first interaction (reduces navigation complexity), local-first architecture (eliminates latency), and AI presence (provides external regulation) addresses different links in the same causal chain. Each feature amplifies the others.
|
||||
**Implication for Magnotia:** The app should feel alive, not static. The convergence of voice-first interaction (reduces navigation complexity), local-first architecture (eliminates latency), and AI presence (provides external regulation) addresses different links in the same causal chain. Each feature amplifies the others.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — Appendix A5: HITL AI Scaffolding -->
|
||||
<!-- Source: Magnotia Master Brief — Appendix A5: HITL AI Scaffolding -->
|
||||
|
||||
## A5. HITL AI Scaffolding — Autonomy-Supportive Design
|
||||
|
||||
@@ -23,4 +23,4 @@
|
||||
4. **Keep the human in the loop** — every AI suggestion requires user confirmation, building executive function rather than atrophying it
|
||||
5. **Design with, not for** — participatory design with neurodivergent users produces fundamentally different and better outcomes
|
||||
|
||||
**Implication for Kon:** The AI agent must be visible, conversational, and interactive — but must never override user autonomy. Every suggestion requires confirmation. The human-in-the-loop feedback mechanism builds metacognitive awareness over time. Users should eventually internalise Kon's scaffolding patterns and need them less — that's a feature, not a failure. LLM prompts must be calibrated for neurodivergent cognition, not neurotypical assumptions.
|
||||
**Implication for Magnotia:** The AI agent must be visible, conversational, and interactive — but must never override user autonomy. Every suggestion requires confirmation. The human-in-the-loop feedback mechanism builds metacognitive awareness over time. Users should eventually internalise Magnotia's scaffolding patterns and need them less — that's a feature, not a failure. LLM prompts must be calibrated for neurodivergent cognition, not neurotypical assumptions.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — Appendix A1: Implementation Intentions -->
|
||||
<!-- Source: Magnotia Master Brief — Appendix A1: Implementation Intentions -->
|
||||
|
||||
## A1. Implementation Intentions — Neurological and Clinical Evidence
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
- **Gilbert et al. 2009** (*Journal of Experimental Psychology: Learning, Memory, and Cognition*): fMRI shows implementation intentions shift activation from the **lateral rostral prefrontal cortex** (effortful top-down control — impaired in ADHD) to the **medial rostral prefrontal cortex** (automatic stimulus-driven control). Better prospective memory performance with *reduced* overall brain activation.
|
||||
- **Paul et al. 2007** (*NeuroReport*): EEG confirms if-then plans normalised the NoGo-P300 amplitude in ADHD children within the **160–312 millisecond window**, consistent with early automatic processing rather than slow deliberate control.
|
||||
|
||||
**Implication for Kon:** The if-then automation feature and voice-activated micro-stepping are neurologically validated mechanisms with a d = 0.99 effect size in the target population. Voice capture must externalise implementation intentions instantaneously, before executive fatigue occurs. The system should prompt users to rehearse plans at least once (amplifies effect) and support varied cue types: time-based, environmental, and emotional.
|
||||
**Implication for Magnotia:** The if-then automation feature and voice-activated micro-stepping are neurologically validated mechanisms with a d = 0.99 effect size in the target population. Voice capture must externalise implementation intentions instantaneously, before executive fatigue occurs. The system should prompt users to rehearse plans at least once (amplifies effect) and support varied cue types: time-based, environmental, and emotional.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — Appendix A4: Latency, Working Memory Decay, and Software Architecture -->
|
||||
<!-- Source: Magnotia Master Brief — Appendix A4: Latency, Working Memory Decay, and Software Architecture -->
|
||||
|
||||
## A4. Latency, Working Memory Decay, and Software Architecture
|
||||
|
||||
@@ -25,4 +25,4 @@
|
||||
**Local-first as cognitive ergonomics:**
|
||||
- **Kleppmann et al. 2019** (*ACM Onward! '19*): Seven ideals of local-first software. Ideal #1 — "No spinners: your work at your fingertips." Primary copy of data on the user's device means read/write operations at local disk speed (sub-millisecond), not network speed (50–500+ ms). Synchronisation happens asynchronously in background.
|
||||
|
||||
**Implication for Kon:** Local-first architecture keeps all interactions within Miller's 100ms direct-manipulation threshold, preventing the WM decay → exploration bias → task abandonment cascade. The 90-second setup threshold is a hard design constraint. Voice capture must work in under 3 seconds from app open.
|
||||
**Implication for Magnotia:** Local-first architecture keeps all interactions within Miller's 100ms direct-manipulation threshold, preventing the WM decay → exploration bias → task abandonment cascade. The 90-second setup threshold is a hard design constraint. Voice capture must work in under 3 seconds from app open.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — Appendix A6: Voice User Interfaces -->
|
||||
<!-- Source: Magnotia Master Brief — Appendix A6: Voice User Interfaces -->
|
||||
|
||||
## A6. Voice User Interfaces as Executive Bypasses
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
- Voice activation bypasses the visual and mechanical bottlenecks of GUI interaction (typing, mouse navigation, visual scanning, sequential menu navigation) — all of which require sustained top-down executive functioning.
|
||||
- Vocalisation is approximately **3x faster** than manual keyboard entry.
|
||||
- VUI design constraints for cognitive accessibility: engineered pauses between phrases for auditory processing time, options presented in text before requiring selection to avoid overloading verbal working memory.
|
||||
- Current voice assistants impose their own setup complexity — Kon must minimise this to near-zero.
|
||||
- Current voice assistants impose their own setup complexity — Magnotia must minimise this to near-zero.
|
||||
|
||||
**Implication for Kon:** Voice is not a convenience feature — it is the primary accessibility mechanism. The 3x speed advantage means voice capture preserves working memory traces that would decay during typing. VUI implementation must include processing pauses and visual confirmation of transcribed text before action. The supply-demand gap (47.6% community interest vs. near-zero academic research) represents a significant opportunity for Kon to generate its own evidence through ethically designed measurement.
|
||||
**Implication for Magnotia:** Voice is not a convenience feature — it is the primary accessibility mechanism. The 3x speed advantage means voice capture preserves working memory traces that would decay during typing. VUI implementation must include processing pauses and visual confirmation of transcribed text before action. The supply-demand gap (47.6% community interest vs. near-zero academic research) represents a significant opportunity for Magnotia to generate its own evidence through ethically designed measurement.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §19 B2B & Enterprise Angle -->
|
||||
<!-- Source: Magnotia Master Brief — §19 B2B & Enterprise Angle -->
|
||||
|
||||
## 19. B2B & Enterprise Angle
|
||||
|
||||
@@ -18,23 +18,23 @@
|
||||
- Explicitly covers ADHD and other neurodivergent conditions under the Equality Act 2010
|
||||
- Software subscriptions, planning apps, and coaching are all fundable
|
||||
- Deepwrk already operates as an Access to Work-approved service — employees claim subscriptions through their grant
|
||||
- **This is the single highest-leverage B2B action Kon can take.** Government effectively subsidises the sale.
|
||||
- **This is the single highest-leverage B2B action Magnotia can take.** Government effectively subsidises the sale.
|
||||
|
||||
### B2B requirements (if/when pursued)
|
||||
- Admin dashboard, SSO (SAML/OAuth), bulk provisioning
|
||||
- Anonymised usage analytics for HR (never individual-level data)
|
||||
- **Anonymised organisational dashboards.** While Kon processes all personal data locally, the B2B tier must output high-level, anonymised telemetry to satisfy enterprise buyers who need metrics to justify software purchases. Examples: "Your team saved 40 hours in task-planning this month", "Average time-to-capture across your organisation: 6 seconds", "82% of users returned after a gap of 3+ days." Critically, these metrics must be aggregated (minimum cohort size of 10 before any data is surfaced), never traceable to individuals, and opt-in at both the user and organisation level. The local-first architecture makes this possible: anonymised summaries can be generated on-device and transmitted as aggregate statistics only — raw data never leaves the machine.
|
||||
- **Anonymised organisational dashboards.** While Magnotia processes all personal data locally, the B2B tier must output high-level, anonymised telemetry to satisfy enterprise buyers who need metrics to justify software purchases. Examples: "Your team saved 40 hours in task-planning this month", "Average time-to-capture across your organisation: 6 seconds", "82% of users returned after a gap of 3+ days." Critically, these metrics must be aggregated (minimum cohort size of 10 before any data is surfaced), never traceable to individuals, and opt-in at both the user and organisation level. The local-first architecture makes this possible: anonymised summaries can be generated on-device and transmitted as aggregate statistics only — raw data never leaves the machine.
|
||||
- GDPR compliance documentation, zero-IT-lift deployment
|
||||
- Users must never be identifiable as neurodivergent to their employer
|
||||
- Position under "universal design" framing — beneficial for all employees
|
||||
|
||||
### Enterprise IT deployment
|
||||
Kon's local-first architecture is simultaneously its biggest B2B selling point and its biggest deployment challenge. Key considerations:
|
||||
Magnotia's local-first architecture is simultaneously its biggest B2B selling point and its biggest deployment challenge. Key considerations:
|
||||
|
||||
- **Local AI model size.** Whisper models range from ~75MB (tiny) to ~1.5GB (large). Enterprise IT teams may flag large binaries or models downloaded to employee machines. Solution: bundle a smaller model by default (tiny/base) with optional upgrade to larger models. Document the model sizes and what they do for IT review.
|
||||
- **No cloud = no enterprise compliance headaches.** Because Kon processes everything on-device with no data transmitted externally, it bypasses the cloud security review, vendor risk assessment, and data processing agreements that typically delay enterprise software procurement by 3–6 months. This is a genuine competitive advantage — frame it explicitly in B2B sales materials.
|
||||
- **Installation permissions.** Enterprise-managed machines often restrict software installation. Kon must be deployable via MDM (Mobile Device Management) tools like Microsoft Intune or Jamf. Tauri's MSIX (Windows) and DMG (macOS) formats are compatible with standard enterprise deployment pipelines.
|
||||
- **No internet dependency.** Kon does not require network access for core functionality. This makes it deployable in air-gapped, high-security, or restricted-network environments — a strong selling point for defence, legal, and healthcare settings.
|
||||
- **No cloud = no enterprise compliance headaches.** Because Magnotia processes everything on-device with no data transmitted externally, it bypasses the cloud security review, vendor risk assessment, and data processing agreements that typically delay enterprise software procurement by 3–6 months. This is a genuine competitive advantage — frame it explicitly in B2B sales materials.
|
||||
- **Installation permissions.** Enterprise-managed machines often restrict software installation. Magnotia must be deployable via MDM (Mobile Device Management) tools like Microsoft Intune or Jamf. Tauri's MSIX (Windows) and DMG (macOS) formats are compatible with standard enterprise deployment pipelines.
|
||||
- **No internet dependency.** Magnotia does not require network access for core functionality. This makes it deployable in air-gapped, high-security, or restricted-network environments — a strong selling point for defence, legal, and healthcare settings.
|
||||
- **Automatic updates.** Enterprise IT will want to control update rollouts. Provide the option to disable auto-updates and instead distribute updates through enterprise channels.
|
||||
|
||||
### Channel partners
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §13 Competitive Landscape (Extended) -->
|
||||
<!-- Source: Magnotia Master Brief — §13 Competitive Landscape (Extended) -->
|
||||
|
||||
## 13. Competitive Landscape (Extended)
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
- Tasks, habits, calendar, mood tracking, journalling with end-to-end encryption on desktop
|
||||
- Privacy-focused, small user base
|
||||
|
||||
### Kon's advantages over the entire field
|
||||
| Kon | The field |
|
||||
### Magnotia's advantages over the entire field
|
||||
| Magnotia | The field |
|
||||
|---|---|
|
||||
| Cross-platform desktop + mobile (Tauri) | Almost all competitors are mobile-first or web-only |
|
||||
| Voice as primary input method | No mature competitor integrates voice into a full planning system |
|
||||
@@ -59,4 +59,4 @@
|
||||
3. **Architecture:** Privacy-conscious and offline-first users served only by open-source tools and tiny startups.
|
||||
4. **Pricing:** Only Structured offers lifetime. Subscription fatigue is extreme in this demographic.
|
||||
|
||||
Kon addresses all four simultaneously. No current competitor does.
|
||||
Magnotia addresses all four simultaneously. No current competitor does.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §4 Design Principles -->
|
||||
<!-- Source: Magnotia Master Brief — §4 Design Principles -->
|
||||
|
||||
### Design principles
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
#### Interaction & UX
|
||||
- **Low-dopamine design.** Non-judgmental tone throughout. No guilt messaging for missed tasks. No aggressive review prompts.
|
||||
- **WIP limits as a design constraint.** The interface must never present more than 1–3 active tasks simultaneously on the primary view. AI prioritises; the UI constrains. A brain dump can contain 50 items — the "Now" view shows only the next action. This is not a nice-to-have; it is the core mechanism for preventing the freeze response.
|
||||
- **Automated context restoration.** Working memory traces decay within ~8 seconds of interruption. If a user clicks away, gets distracted, or closes the app mid-task, Kon must perfectly preserve their exact state — cursor position, active timer, active task, scroll position — so they can resume with zero "Where was I?" cognitive latency. This must be seamless and automatic. No "Resume session?" dialogue. Just open the app and be exactly where you left off.
|
||||
- **Automated context restoration.** Working memory traces decay within ~8 seconds of interruption. If a user clicks away, gets distracted, or closes the app mid-task, Magnotia must perfectly preserve their exact state — cursor position, active timer, active task, scroll position — so they can resume with zero "Where was I?" cognitive latency. This must be seamless and automatic. No "Resume session?" dialogue. Just open the app and be exactly where you left off.
|
||||
- **Literal labels always.** Ambiguous icons (standalone gear, hamburger menu) force literal thinkers to guess function, expending precious mental energy. Always pair icons with literal text labels.
|
||||
- **Progressive disclosure.** Break complex onboarding or tasks down to reveal only the immediate next step, preventing the brain from freezing.
|
||||
- **Motion control.** All non-essential animation and auto-playing media must be off by default or controlled via a prominent "Reduce Motion" / "Calm Mode" toggle. Unexpected animations can cause physical distress and sensory overload.
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
#### Onboarding
|
||||
- Must be understandable within 30 seconds. If a neurodivergent user can't figure it out immediately, they won't return.
|
||||
- **90-second hard threshold.** Empirical HCI research (see Appendix A4) shows that tools taking longer than 90 seconds to configure trigger task abandonment cascades in ADHD users, increasing cognitive load by 2.3x. No feature in Kon should require more than 90 seconds of setup. Voice capture must work in under 3 seconds from app open.
|
||||
- **90-second hard threshold.** Empirical HCI research (see Appendix A4) shows that tools taking longer than 90 seconds to configure trigger task abandonment cascades in ADHD users, increasing cognitive load by 2.3x. No feature in Magnotia should require more than 90 seconds of setup. Voice capture must work in under 3 seconds from app open.
|
||||
- Progressive disclosure applies here especially — show one step at a time, never the full complexity.
|
||||
|
||||
#### Future consideration: adaptive UI
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §17 Desktop Distribution Deep Dive -->
|
||||
<!-- Source: Magnotia Master Brief — §17 Desktop Distribution Deep Dive -->
|
||||
|
||||
## 17. Desktop Distribution Deep Dive
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<!-- Source: Kon Master Brief — §7 Distribution Strategy -->
|
||||
<!-- Source: Magnotia Master Brief — §7 Distribution Strategy -->
|
||||
|
||||
## 7. Distribution Strategy
|
||||
|
||||
### Marketing positioning
|
||||
|
||||
**What Kon is NOT:** A to-do list. A habit tracker. Another productivity app. The market is flooded with generic productivity tools, and ADHD users have severe app fatigue from trying and abandoning dozens of them. Positioning Kon in that category is death.
|
||||
**What Magnotia is NOT:** A to-do list. A habit tracker. Another productivity app. The market is flooded with generic productivity tools, and ADHD users have severe app fatigue from trying and abandoning dozens of them. Positioning Magnotia in that category is death.
|
||||
|
||||
**What Kon IS:** An "external brain." A prosthetic prefrontal cortex designed for cognitive offloading. The app does the heavy cognitive lifting — it takes raw, messy thoughts via voice and automatically decomposes them into verb-led micro-steps (e.g. "Clean the house" → "Pick up one item of clothing from the bedroom floor").
|
||||
**What Magnotia IS:** An "external brain." A prosthetic prefrontal cortex designed for cognitive offloading. The app does the heavy cognitive lifting — it takes raw, messy thoughts via voice and automatically decomposes them into verb-led micro-steps (e.g. "Clean the house" → "Pick up one item of clothing from the bedroom floor").
|
||||
|
||||
**Key messaging pillars:**
|
||||
1. **"Your brain moves fast. Kon catches it."** — Voice-first capture, zero friction, thoughts don't get lost.
|
||||
1. **"Your brain moves fast. Magnotia catches it."** — Voice-first capture, zero friction, thoughts don't get lost.
|
||||
2. **"Local. Private. Yours forever."** — Nothing leaves your device. No cloud. No subscriptions for core features. Your vulnerabilities are never exposed.
|
||||
3. **"Built by a neurodivergent brain, for neurodivergent brains."** — Authenticity. Jake has executive dysfunction. This isn't corporate empathy theatre.
|
||||
4. **"They took away lifetime. We never will."** — Direct competitive positioning against Tiimo's subscription-only model.
|
||||
@@ -19,7 +19,7 @@
|
||||
### Distribution channels
|
||||
|
||||
**Desktop distribution:**
|
||||
- **Primary:** Direct download from kon.app via Lemon Squeezy or Paddle (5% + 50p per transaction). Signed and notarised builds for macOS (£79/year Apple Developer Programme) and code-signed for Windows (EV certificate, £240–£480/year).
|
||||
- **Primary:** Direct download from magnotia.app via Lemon Squeezy or Paddle (5% + 50p per transaction). Signed and notarised builds for macOS (£79/year Apple Developer Programme) and code-signed for Windows (EV certificate, £240–£480/year).
|
||||
- **Microsoft Store (supplementary):** Free to list, 250M monthly active users, 0% commission if using own payment system. Good for discovery.
|
||||
- **Mac App Store (evaluate):** 15% commission under Small Business Programme, sandboxing may limit Tauri features. Most successful indie Mac apps distribute directly.
|
||||
- **Linux:** Flathub (1M+ active users, pre-installed on major distros) + AppImage for direct download.
|
||||
@@ -42,7 +42,7 @@
|
||||
**SEO opportunity:** Long-tail terms like "ADHD app for Windows" and "focus timer desktop app" face lower competition than mobile-focused searches. Obsidian gets 52.9% of traffic from organic search — proof that desktop-first apps can win on SEO.
|
||||
|
||||
### Phase 0 — Pre-beta (this week)
|
||||
- [ ] Register domain (kon.app or getkon.app)
|
||||
- [ ] Register domain (magnotia.app or getmagnotia.app)
|
||||
- [ ] Build one-page landing page on Carrd (£16/year) or Framer (free tier). Hero must answer three questions in under 5 seconds: what is this, who is it for, what do I do next. Landing page copy written at 5th–7th grade reading level (converts at 11.1% vs. 5.3% for university-level copy). Include 15–30 second silent auto-play GIF showing voice-to-task flow. Single CTA button.
|
||||
- [ ] Set up waitlist with LaunchList (£65 one-time). Includes gamified referral mechanics, anti-spam filtering. Alternative: ConvertKit (free to 1,000 subscribers) + Tally form.
|
||||
- [ ] Set up analytics with Plausible.io (privacy-friendly, no cookie banner needed).
|
||||
@@ -57,8 +57,8 @@
|
||||
- [ ] Run Van Westendorp pricing survey via Tally (free) to validate £49 price point before committing
|
||||
|
||||
### Phase 2 — Community seeding (weeks 2–4)
|
||||
- [ ] **Reddit (priority 1):** r/ADHD (2.1M members), r/adhdwomen, r/ADHD_Programmers, r/autism, r/neurodiversity, r/executivedysfunction. Spend 4+ weeks genuinely contributing before any mention of Kon (Reddit 10:1 rule). When ready: authentic posts, no sales pitches. Use F5Bot (free) to monitor keywords: "ADHD app", "voice to-do", "ADHD task manager."
|
||||
- [ ] **Obsidian/PKM communities (priority 2):** Show Kon → Obsidian workflow (voice dump → transcription → tasks → Obsidian vault). Use as amplifiers, not primary sales channel.
|
||||
- [ ] **Reddit (priority 1):** r/ADHD (2.1M members), r/adhdwomen, r/ADHD_Programmers, r/autism, r/neurodiversity, r/executivedysfunction. Spend 4+ weeks genuinely contributing before any mention of Magnotia (Reddit 10:1 rule). When ready: authentic posts, no sales pitches. Use F5Bot (free) to monitor keywords: "ADHD app", "voice to-do", "ADHD task manager."
|
||||
- [ ] **Obsidian/PKM communities (priority 2):** Show Magnotia → Obsidian workflow (voice dump → transcription → tasks → Obsidian vault). Use as amplifiers, not primary sales channel.
|
||||
- [ ] **TikTok product seeding (priority 3):** DM 20–50 ADHD micro-influencers (1K–50K followers) with free lifetime licences. Zero obligation to post. Cost per seed: £0 (digital product). Outreach must reference a specific video the creator made. Follow up with affiliate link at 25–30% commission via Lemon Squeezy.
|
||||
- [ ] Submit to ADHD UK discovery platform and ADDitude Magazine tool roundups.
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<!-- Source: Kon Master Brief — §4 Feature Set -->
|
||||
<!-- Source: Magnotia Master Brief — §4 Feature Set -->
|
||||
|
||||
## 4. Feature Set
|
||||
|
||||
### Core MVP (shipping with beta)
|
||||
- Local AI transcription (Whisper, on-device)
|
||||
- Auto-populating to-do lists from transcriptions
|
||||
- **Visual time representation.** Tasks displayed as visual blocks of time or countdowns, not just text lists. Traditional text-based to-do lists trigger overwhelm — visual timelines directly combat time blindness. This is the #1 community-requested feature and Tiimo's primary strength. Kon must match or exceed it from day one. Time should be externalised using visual countdown timers (e.g. shrinking colour disks, filling progress rings) rather than standard digital clocks — making the passage of time concrete and anchoring focus for users with time agnosia.
|
||||
- **Visual time representation.** Tasks displayed as visual blocks of time or countdowns, not just text lists. Traditional text-based to-do lists trigger overwhelm — visual timelines directly combat time blindness. This is the #1 community-requested feature and Tiimo's primary strength. Magnotia must match or exceed it from day one. Time should be externalised using visual countdown timers (e.g. shrinking colour disks, filling progress rings) rather than standard digital clocks — making the passage of time concrete and anchoring focus for users with time agnosia.
|
||||
- **WIP limits.** The main screen must mathematically restrict how many active tasks are visible at once. A "Now" column showing only 1–3 items maximum. Auto-generated task lists that dump 30 items onto a screen will instantly trigger the freeze response. The AI can prioritise; the UI must constrain.
|
||||
- History of past voice notes and transcriptions
|
||||
- Light/dark mode
|
||||
- Templates with local AI agent (contextual text under headings with associated metadata)
|
||||
- Vocabulary profiles (custom dictionaries for specialist terms — e.g. DND NPC/location names, technical jargon)
|
||||
- Transcription of uploaded voice notes and media files
|
||||
- **Open data format.** All transcripts and task lists stored locally in plain text, JSON, or Markdown. Essential for the privacy-first and PKM audience. Enables the Kon → Obsidian workflow promised in the distribution strategy. Users must be able to export, move, and own their data without vendor lock-in.
|
||||
- **Open data format.** All transcripts and task lists stored locally in plain text, JSON, or Markdown. Essential for the privacy-first and PKM audience. Enables the Magnotia → Obsidian workflow promised in the distribution strategy. Users must be able to export, move, and own their data without vendor lock-in.
|
||||
|
||||
### Post-MVP features (validated, designed, not yet prioritised)
|
||||
- **AI-powered micro-stepping with "just start" timer.** Decomposing abstract goals into hyper-specific actionable steps. The local AI agent must generate micro-steps that begin with highly specific, low-friction action verbs. Linguistic rules: every generated step must start with a concrete physical verb, target one single action, and be completable in under 5 minutes. Example: "Clean room" → "Pick up one shirt from the floor." NOT "Organise your bedroom" (still abstract, still paralysing). The goal is to bypass executive dysfunction by removing all ambiguity about what "starting" means. **Paired with a 2-minute or 5-minute "just start" focus timer.** Committing to a task for just five minutes bypasses internal resistance and builds micro-momentum — users frequently work past the timer. The timer should be a single tap from any micro-step, visually prominent, and use a shrinking colour disk or similar visual countdown (not a digital clock) to externalise the passage of time and combat time blindness.
|
||||
@@ -25,5 +25,5 @@
|
||||
- **Read Page Aloud (text-to-speech).** A simple TTS function that reads transcriptions, task lists, or AI-generated micro-steps aloud. Engages auditory processing alongside visual, which improves retention and comprehension for ADHD users. Particularly valuable during the "Clarify" stage when reviewing a brain dump. Use OS-native TTS engines (available on all target platforms) to avoid additional dependencies. Should be a single-tap action from any text view.
|
||||
|
||||
### Parked / future consideration
|
||||
- **AI body doubling (low-fi implementation).** Research strongly validates the concept (rated #1 ADHD workplace strategy in 2025 ADDitude survey; 12-week study showed focus doubling, 30% anxiety reduction, £37 public value per £1 invested). Body doubling doesn't require high-fidelity interaction — simple ambient presence and shared monitoring work. A "low-fi" version could be a "Focus Room" interface showing abstract statuses ("AI is sorting your tasks…", "3 other Kon users are in deep work right now") to provide the feeling of parallel presence without complex engineering. This sidesteps the need for video, voice, or real-time communication. Potential future subscription feature. Not in MVP scope but worth prototyping early — the implementation cost is low relative to the validated demand.
|
||||
- **AI body doubling (low-fi implementation).** Research strongly validates the concept (rated #1 ADHD workplace strategy in 2025 ADDitude survey; 12-week study showed focus doubling, 30% anxiety reduction, £37 public value per £1 invested). Body doubling doesn't require high-fidelity interaction — simple ambient presence and shared monitoring work. A "low-fi" version could be a "Focus Room" interface showing abstract statuses ("AI is sorting your tasks…", "3 other Magnotia users are in deep work right now") to provide the feeling of parallel presence without complex engineering. This sidesteps the need for video, voice, or real-time communication. Potential future subscription feature. Not in MVP scope but worth prototyping early — the implementation cost is low relative to the validated demand.
|
||||
- Temptation bundling — cut (OS-level integration nightmare across platforms, essentially impossible on iOS). Replaced by energy-aware task sequencing (see post-MVP features).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §15 Feature Validation from Research -->
|
||||
<!-- Source: Magnotia Master Brief — §15 Feature Validation from Research -->
|
||||
|
||||
## 15. Feature Validation from Research
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §18 ADHD Content Creator & Influencer Landscape -->
|
||||
<!-- Source: Magnotia Master Brief — §18 ADHD Content Creator & Influencer Landscape -->
|
||||
|
||||
## 18. ADHD Content Creator & Influencer Landscape
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
### UK advocacy organisations
|
||||
- **ADHD Foundation:** Largest user-led ADHD organisation in Europe
|
||||
- **ADHD UK:** Launched a discovery platform reviewing tools and strategies — natural fit for Kon
|
||||
- **ADHD UK:** Launched a discovery platform reviewing tools and strategies — natural fit for Magnotia
|
||||
- **Neurodiversity in Business:** Corporate-facing charity
|
||||
|
||||
### Sponsorship costs
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §8 Key Risks -->
|
||||
<!-- Source: Magnotia Master Brief — §8 Key Risks -->
|
||||
|
||||
## 8. Key Risks
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
| Zero distribution infrastructure | 90-day calendar above. LaunchList + Reddit + TikTok seeding + Product Hunt. Total budget: £81. |
|
||||
| Lifetime pricing limits long-term revenue | Cloud tier provides recurring revenue. Monitor conversion rate. Launch pricing for first 500 creates urgency. |
|
||||
| Scope creep from secondary audiences (TTRPG, B2B) | Neurodivergent beachhead ONLY until validated. No feature work for secondary audiences until £2K MRR. |
|
||||
| Nobody has seen Kon yet — zero external validation | Beta this week fixes this. Share embarrassingly early. |
|
||||
| Nobody has seen Magnotia yet — zero external validation | Beta this week fixes this. Share embarrassingly early. |
|
||||
| ADHD app market high abandonment rate | Design around the shame spiral. Welcome users back without judgement. Never punish inconsistency. Grace day recovery rate is the key metric. |
|
||||
| Lifetime pricing economics break if cloud costs grow | Keep cloud tier strictly optional. Base product must remain sustainable on one-time revenue alone. |
|
||||
| EAA compliance required as Kon grows beyond microenterprise threshold | Build to WCAG 2.2 AA from day one. Publish VPAT before competitors do. |
|
||||
| EAA compliance required as Magnotia grows beyond microenterprise threshold | Build to WCAG 2.2 AA from day one. Publish VPAT before competitors do. |
|
||||
| cr-sqlite development pace has slowed since late 2024 | Core CRDT logic is sound and self-contained. Fallback: Automerge + SQLite BLOB storage, reusing entire iroh/mDNS networking stack unchanged. |
|
||||
| Code signing costs are unavoidable | macOS £79/year + Windows £240–£480/year = ~£320–£560/year minimum. Budget from first revenue. |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §6 Legal & Compliance -->
|
||||
<!-- Source: Magnotia Master Brief — §6 Legal & Compliance -->
|
||||
|
||||
## 6. Legal & Compliance
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
- **Budget impact:** ~£320–£560/year minimum for macOS + Windows signing. Non-optional cost.
|
||||
|
||||
### GDPR position (local-only tier)
|
||||
- **Jake is NOT a data processor.** Kon runs entirely on-device. No data is transmitted, stored, or visible to the developer. Same legal position as distributing a word processor.
|
||||
- **Special category data:** Marketing targets neurodivergent users, but the app does not collect, store, or infer diagnosis information. Per ICO guidance, a "possible inference" is not special category data — only "reasonable certainty" triggers Article 9. Kon is on safe ground here.
|
||||
- **Jake is NOT a data processor.** Magnotia runs entirely on-device. No data is transmitted, stored, or visible to the developer. Same legal position as distributing a word processor.
|
||||
- **Special category data:** Marketing targets neurodivergent users, but the app does not collect, store, or infer diagnosis information. Per ICO guidance, a "possible inference" is not special category data — only "reasonable certainty" triggers Article 9. Magnotia is on safe ground here.
|
||||
- **Voice data:** Processed locally by Whisper. Never leaves the device. No third-party processor involved.
|
||||
|
||||
### GDPR position (cloud tier — when added)
|
||||
@@ -22,10 +22,10 @@
|
||||
- Enforceable from 28 June 2025. Applies to consumer-facing digital products sold in the EU, including apps.
|
||||
- Technical benchmark: EN 301 549 V3.2.1, incorporating WCAG 2.1 Level AA.
|
||||
- Applies to non-EU companies selling to EU customers (similar extraterritorial reach to GDPR).
|
||||
- Microenterprises (fewer than 10 employees, under €2M turnover) are currently exempt — Kon qualifies initially.
|
||||
- Microenterprises (fewer than 10 employees, under €2M turnover) are currently exempt — Magnotia qualifies initially.
|
||||
- **The UK has not adopted the EAA.** UK relies on the Equality Act 2010 ("reasonable adjustments") with no specific technical standards enforced.
|
||||
- **Competitive opportunity:** Neither Tiimo nor Structured publishes a VPAT or formal accessibility conformance report. Publishing one first opens doors to government procurement, educational institutions, and enterprise contracts.
|
||||
- Build to WCAG 2.2 AA from day one — this aligns with Kon's design philosophy and creates a genuine compliance moat.
|
||||
- Build to WCAG 2.2 AA from day one — this aligns with Magnotia's design philosophy and creates a genuine compliance moat.
|
||||
|
||||
### Required before paid launch
|
||||
- [ ] Privacy policy (no data leaves device, no telemetry, no identifying analytics)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §16 Lifetime Licence Economics -->
|
||||
<!-- Source: Magnotia Master Brief — §16 Lifetime Licence Economics -->
|
||||
|
||||
## 16. Lifetime Licence Economics
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- **Affinity (Serif):** Perpetual licences (~£40/app, £135 suite) for 23 years. 53% profit margins. Acquired by Canva for ~£410M.
|
||||
- **iA Writer:** £40 Mac, £24 Windows, £16 iOS one-time. Free updates for 7+ years. Profitable with team of 12, entirely bootstrapped. Android experiment showed 50/50 split between one-time (£24) and subscription (£4/year), but purchases generated 2–3x more total revenue with significantly better retention.
|
||||
- **Sublime Text:** £79 perpetual licence with paid major-version upgrades. Sustained a tiny team for over a decade.
|
||||
- **Obsidian:** Free core + £3.20/month Sync, £6.40/month Publish. Clearest precedent for Kon's hybrid model.
|
||||
- **Obsidian:** Free core + £3.20/month Sync, £6.40/month Publish. Clearest precedent for Magnotia's hybrid model.
|
||||
|
||||
### Risks
|
||||
- Revenue plateaus once addressable market is saturated, while support costs continue indefinitely.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §11 Market Size & Demographics -->
|
||||
<!-- Source: Magnotia Master Brief — §11 Market Size & Demographics -->
|
||||
|
||||
## 11. Market Size & Demographics
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!-- Source: Kon Master Brief — Part 2: The 9-Pattern Micro-SaaS Playbook -->
|
||||
<!-- Source: Magnotia Master Brief — Part 2: The 9-Pattern Micro-SaaS Playbook -->
|
||||
|
||||
# PART 2: THE 9-PATTERN MICRO-SAAS PLAYBOOK
|
||||
|
||||
**Reference.** Distilled from 30+ Starter Story case studies, founder interviews (Tibo, Mike Hill, Kleo/Lara), and cross-referenced with 4,400+ written case studies. Each pattern is mapped to Kon's current position with specific next actions.
|
||||
**Reference.** Distilled from 30+ Starter Story case studies, founder interviews (Tibo, Mike Hill, Kleo/Lara), and cross-referenced with 4,400+ written case studies. Each pattern is mapped to Magnotia's current position with specific next actions.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
**The principle:** The most consistent origin story across successful micro-SaaS. The founder was the customer first. Prerender.io, Kleo, Analyzify, Refiner — all built by people solving their own problem.
|
||||
|
||||
**Kon's position: ✅ Strong.**
|
||||
Jake has executive dysfunction. He searched for an offline-first, voice-driven productivity tool for neurodivergent users, couldn't find one that wasn't cloud-dependent or iOS-exclusive, and started building Kon for himself. This is the textbook origin story.
|
||||
**Magnotia's position: ✅ Strong.**
|
||||
Jake has executive dysfunction. He searched for an offline-first, voice-driven productivity tool for neurodivergent users, couldn't find one that wasn't cloud-dependent or iOS-exclusive, and started building Magnotia for himself. This is the textbook origin story.
|
||||
|
||||
**Next action:** Make this the centrepiece of every piece of marketing. "I'm neurodivergent. I built this because nothing else worked for me." Authenticity is the single most powerful distribution asset in neurodivergent communities.
|
||||
|
||||
@@ -21,7 +21,7 @@ Jake has executive dysfunction. He searched for an offline-first, voice-driven p
|
||||
|
||||
**The principle:** Find products already making money despite having terrible UX or obvious gaps. If people pay for something broken, the market is proven — you just build better. Mike Hill's entire philosophy.
|
||||
|
||||
**Kon's position: ✅ Strong.**
|
||||
**Magnotia's position: ✅ Strong.**
|
||||
- **Tiimo:** iPhone App of the Year 2025, $200K/month revenue. iOS-only, no Android, no native desktop, cloud-dependent, no voice transcription, subscription-only (removed lifetime option to community backlash), aggressive review prompts.
|
||||
- **WhisperFlow and similar:** Cloud-dependent, premium pricing, no task management integration.
|
||||
- **Todoist, Notion, etc.:** Not designed for neurodivergent brains, subscription-heavy, cognitively overwhelming.
|
||||
@@ -36,7 +36,7 @@ The market is proven. People are paying. The incumbents have obvious, exploitabl
|
||||
|
||||
**The principle:** Pick a niche so narrow that big players ignore it, then own it completely. Email signature generators, WhatsApp plugins for Shopify, digital signage for cafes. The narrower the niche, the less competition and the higher the conversion rate.
|
||||
|
||||
**Kon's position: ✅ Strong.**
|
||||
**Magnotia's position: ✅ Strong.**
|
||||
"Voice-first, local-only productivity app for neurodivergent people with executive dysfunction" is extremely narrow. No big player is going to build this. Tiimo is the closest and they're a 40-person VC-funded Copenhagen team that still can't get Android working.
|
||||
|
||||
**Next action:** Resist the temptation to broaden. "Productivity for everyone" is how you become invisible. Stay locked on neurodivergent users until you hit £2K MRR. The TTRPG and B2B angles can wait.
|
||||
@@ -47,7 +47,7 @@ The market is proven. People are paying. The incumbents have obvious, exploitabl
|
||||
|
||||
**The principle:** "Shipped in 12 hours and now makes $15K/month." Validation speed matters more than product perfection. Pre-sell first, build second (Gil's model). Revenue before polish.
|
||||
|
||||
**Kon's position: ✅ Strong.**
|
||||
**Magnotia's position: ✅ Strong.**
|
||||
MVP is nearly ready. Jake can rebuild from scratch in a day. Tauri/Svelte/Rust stack enables rapid iteration. Beta testers this weekend.
|
||||
|
||||
**Next action:** Ship the beta this weekend. Don't polish — test. The goal is not "is it beautiful" but "does the brain dump → task list flow actually work?" If the core loop works, everything else is iteration.
|
||||
@@ -58,11 +58,11 @@ MVP is nearly ready. Jake can rebuild from scratch in a day. Tauri/Svelte/Rust s
|
||||
|
||||
**The principle:** The loudest message across all 30 videos. Most builders skip distribution because it means doing "the hard thing" — talking to people. A great product with no distribution dies. A decent product with great distribution wins.
|
||||
|
||||
**Kon's position: ⚠️ Critical gap.**
|
||||
Zero distribution infrastructure. No landing page, no waitlist, no domain, no social presence for Kon. Nobody outside Jake's immediate circle has seen it.
|
||||
**Magnotia's position: ⚠️ Critical gap.**
|
||||
Zero distribution infrastructure. No landing page, no waitlist, no domain, no social presence for Magnotia. Nobody outside Jake's immediate circle has seen it.
|
||||
|
||||
**Next actions (in order):**
|
||||
1. Register domain this week (kon.app or getkon.app).
|
||||
1. Register domain this week (magnotia.app or getmagnotia.app).
|
||||
2. One-page landing page with waitlist signup live by Monday.
|
||||
3. Roo's nonprofit network gets the link first.
|
||||
4. Reddit posts in r/ADHD, r/adhdwomen, r/ADHD_Programmers, r/autism — authentic, not salesy.
|
||||
@@ -76,7 +76,7 @@ This is the make-or-break pattern. Everything else is in place. Distribution is
|
||||
|
||||
**The principle:** Kleo's playbook — don't launch publicly. Build a waitlist using content, run mini-launches to waitlist subscribers only, create FOMO through scarcity ("you can't buy this, you need to join the waitlist"), and hit £30K MRR in four days. Lara took info-product launch tactics (webinars, email sequences, urgency) and applied them to SaaS.
|
||||
|
||||
**Kon's position: ⚠️ Planned but not yet started.**
|
||||
**Magnotia's position: ⚠️ Planned but not yet started.**
|
||||
Jake intends to do an invite-only beta to create scarcity and mystique. The instinct is right — this maps directly to Kleo's playbook.
|
||||
|
||||
**Next actions:**
|
||||
@@ -91,10 +91,10 @@ Jake intends to do an invite-only beta to create scarcity and mystique. The inst
|
||||
|
||||
**The principle:** Mike Hill is emphatic — every one of his founding teams has a designer. Good design sells. Target incumbents with bad UX. When your product looks and feels better, it becomes self-selling.
|
||||
|
||||
**Kon's position: ✅ Strong.**
|
||||
Tauri/Svelte produces a native, fast UI. The design brief includes research-backed neurodivergent-specific design principles: Lexend/Atkinson Hyperlegible typography, sensory colour zoning, no halation, progressive disclosure, literal labels, motion control, forgiving interaction patterns. This level of design intentionality is a genuine moat — Tiimo is good but Kon's design spec is more deeply grounded in the research.
|
||||
**Magnotia's position: ✅ Strong.**
|
||||
Tauri/Svelte produces a native, fast UI. The design brief includes research-backed neurodivergent-specific design principles: Lexend/Atkinson Hyperlegible typography, sensory colour zoning, no halation, progressive disclosure, literal labels, motion control, forgiving interaction patterns. This level of design intentionality is a genuine moat — Tiimo is good but Magnotia's design spec is more deeply grounded in the research.
|
||||
|
||||
**Next action:** Make the design visible in marketing. Screenshots, screen recordings, and side-by-side comparisons with competitors. "Here's what Tiimo looks like. Here's what Kon looks like. Notice the difference." Let the design sell itself.
|
||||
**Next action:** Make the design visible in marketing. Screenshots, screen recordings, and side-by-side comparisons with competitors. "Here's what Tiimo looks like. Here's what Magnotia looks like. Notice the difference." Let the design sell itself.
|
||||
|
||||
---
|
||||
|
||||
@@ -102,7 +102,7 @@ Tauri/Svelte produces a native, fast UI. The design brief includes research-back
|
||||
|
||||
**The principle:** Almost universally, successful micro-SaaS founders are bootstrapped. Mike Hill's model: 4 co-founders, 25% equity each, grow to £10K MRR to cover costs, then split profits as salary. No VC, no bloated teams. His explicit quote: "these businesses are about bigger salaries, not big exits."
|
||||
|
||||
**Kon's position: ✅ Strong.**
|
||||
**Magnotia's position: ✅ Strong.**
|
||||
Solo founder. No VC. No team overhead. Near-zero infrastructure costs (local-first means no servers for the base product). Lifetime pricing + optional cloud subscription. Revenue goes directly to Jake.
|
||||
|
||||
**Next action:** Set a clear personal revenue target. What number makes this worth maintaining? £500/month covers costs and proves viability. £2K/month funds CORBEL growth. £5K/month is a genuine second income stream. Know your number so you can measure against it.
|
||||
@@ -113,14 +113,14 @@ Solo founder. No VC. No team overhead. Near-zero infrastructure costs (local-fir
|
||||
|
||||
**The principle:** The highest earners aren't running one product — they're running five or six. Tibo has five apps (combined £700K/month). Mike Hill has five (combined £200K/month). Risk distribution: if one stalls, others keep growing. Each new product follows the same repeatable playbook.
|
||||
|
||||
**Kon's position: ⏳ Not relevant yet.**
|
||||
This is product #1. The playbook only applies once Kon is generating revenue and the system is proven. Then Jake can ask: "What's the next niche I can apply this exact process to?"
|
||||
**Magnotia's position: ⏳ Not relevant yet.**
|
||||
This is product #1. The playbook only applies once Magnotia is generating revenue and the system is proven. Then Jake can ask: "What's the next niche I can apply this exact process to?"
|
||||
|
||||
**Next action:** None right now. Focus entirely on Kon. But document everything — what worked, what didn't, what you'd do differently. When the time comes for product #2, you'll have a personal playbook to run again.
|
||||
**Next action:** None right now. Focus entirely on Magnotia. But document everything — what worked, what didn't, what you'd do differently. When the time comes for product #2, you'll have a personal playbook to run again.
|
||||
|
||||
---
|
||||
|
||||
### Playbook Summary: Where Kon Stands
|
||||
### Playbook Summary: Where Magnotia Stands
|
||||
|
||||
| Pattern | Status | Priority |
|
||||
|---|---|---|
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §10 Open Questions -->
|
||||
<!-- Source: Magnotia Master Brief — §10 Open Questions -->
|
||||
|
||||
## 10. Open Questions
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- Source: Kon Master Brief — §5 Pricing Model -->
|
||||
<!-- Source: Magnotia Master Brief — §5 Pricing Model -->
|
||||
|
||||
## 5. Pricing Model
|
||||
|
||||
### Free tier
|
||||
Basic voice capture + local transcription + simple task list. Limited functionality (e.g. 5 active tasks or 10 stored transcriptions). Top-of-funnel — proves the core value loop.
|
||||
|
||||
### Kon Pro — lifetime licence
|
||||
### Magnotia Pro — lifetime licence
|
||||
| Platform | Price |
|
||||
|---|---|
|
||||
| Desktop (Windows/macOS/Linux) | £49 |
|
||||
@@ -16,7 +16,7 @@ Full feature set, all running locally. Unlimited transcription, templates, profi
|
||||
|
||||
**Positioning:** "They took away lifetime. We never will."
|
||||
|
||||
### Kon Cloud — optional subscription (£4.99/month or £39.99/year)
|
||||
### Magnotia Cloud — optional subscription (£4.99/month or £39.99/year)
|
||||
Access to frontier AI model (Claude, GPT-4o, or similar) for:
|
||||
- Higher-accuracy transcription of specialist vocabulary
|
||||
- Smarter task decomposition
|
||||
@@ -44,7 +44,7 @@ This is the only recurring revenue stream and is genuinely tied to per-request A
|
||||
|
||||
### Pre-launch pricing validation (Van Westendorp)
|
||||
Before committing to £49, send the waitlist a four-question survey via Tally (free):
|
||||
1. At what price would Kon be so expensive you'd never buy it?
|
||||
1. At what price would Magnotia be so expensive you'd never buy it?
|
||||
2. At what price would it seem so cheap you'd doubt its quality?
|
||||
3. At what price is it getting expensive but you'd still consider it?
|
||||
4. At what price is it a bargain?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §20 Research Gaps Still to Investigate -->
|
||||
<!-- Source: Magnotia Master Brief — §20 Research Gaps Still to Investigate -->
|
||||
|
||||
## 20. Research Gaps Still to Investigate
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §9 Success Metrics -->
|
||||
<!-- Source: Magnotia Master Brief — §9 Success Metrics -->
|
||||
|
||||
## 9. Success Metrics
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
|
||||
### Neuro-inclusive product metrics
|
||||
|
||||
Standard SaaS metrics like Daily Active Users (DAU) or unbroken streaks must be avoided — they encourage the exact shame spiral Kon is designed to prevent. Track these instead:
|
||||
Standard SaaS metrics like Daily Active Users (DAU) or unbroken streaks must be avoided — they encourage the exact shame spiral Magnotia is designed to prevent. Track these instead:
|
||||
|
||||
| Metric | What it measures | Why it matters |
|
||||
|---|---|---|
|
||||
| **Time-to-capture** | Seconds from app open to completed brain dump | Measures friction. If this exceeds 10 seconds, the thought is gone. The lower this number, the better Kon serves its core purpose. |
|
||||
| **Grace day recovery rate** | % of users who return and complete a task after 1+ days of inactivity | Proves Kon has beaten the abandon-shame cycle. This is the single most important product metric. If users come back after missing days without guilt, the design is working. |
|
||||
| **Time-to-capture** | Seconds from app open to completed brain dump | Measures friction. If this exceeds 10 seconds, the thought is gone. The lower this number, the better Magnotia serves its core purpose. |
|
||||
| **Grace day recovery rate** | % of users who return and complete a task after 1+ days of inactivity | Proves Magnotia has beaten the abandon-shame cycle. This is the single most important product metric. If users come back after missing days without guilt, the design is working. |
|
||||
| **Micro-step completion rate** | Completion rate of AI-decomposed tasks vs. manually entered abstract tasks | Validates that micro-stepping actually works. If AI-generated steps have higher completion rates than user-entered tasks, the feature is earning its keep. |
|
||||
| **Brain dump → task conversion** | % of voice transcription content that converts into actionable tasks | Measures AI quality. Low conversion means the AI isn't parsing well; high conversion means the core loop works. |
|
||||
| **Return after lapse** | Median days between last session and next session for users who go inactive | Measures stickiness without punishing breaks. A user who returns after 2 weeks is a success, not a failure. |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §2 Target Audience -->
|
||||
<!-- Source: Magnotia Master Brief — §2 Target Audience -->
|
||||
|
||||
## 2. Target Audience
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §3 Tech Stack -->
|
||||
<!-- Source: Magnotia Master Brief — §3 Tech Stack -->
|
||||
|
||||
## 3. Tech Stack
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
| Optimal | 32GB | Llama 3.3 8B | Q5_K_M | ~5.5GB | 10–20 tok/s |
|
||||
| Mobile | 4–6GB | Llama 3.2 1B | Q4_K_M | ~0.8GB | 30–50 tok/s |
|
||||
|
||||
- **Benchmarks:** Ryzen 5700G (DDR4) achieves ~11 tok/s on 7B Q4_K_M. Apple M3 base achieves ~26 tok/s. For Kon's use case (50–200 token responses for task decomposition), 10–15 tok/s is perfectly usable (1–10 seconds per response).
|
||||
- **Benchmarks:** Ryzen 5700G (DDR4) achieves ~11 tok/s on 7B Q4_K_M. Apple M3 base achieves ~26 tok/s. For Magnotia's use case (50–200 token responses for task decomposition), 10–15 tok/s is perfectly usable (1–10 seconds per response).
|
||||
- **Minimum published spec:** 8GB RAM, any CPU from 2020+. Below 8GB is not supported.
|
||||
|
||||
### Local RAG pipeline
|
||||
@@ -46,7 +46,7 @@
|
||||
### Cross-device sync (post-MVP)
|
||||
- **CRDT layer:** cr-sqlite (vlcn.io, ~3,500 GitHub stars, core Rust). Operates at the SQL level — `SELECT crsql_as_crr('tasks')` converts any table to a Conflict-free Replicated Relation. Normal SQL continues working. Metadata overhead: ~50–100 bytes per modified cell.
|
||||
- **Networking:** iroh (n0-computer/iroh, ~7,900 GitHub stars, pure Rust, v0.96+). Dials peers by Ed25519 public key. Auto-selects best path: direct QUIC on LAN, NAT hole-punching on WAN, or encrypted relay fallback. QUIC with TLS 1.3. Relays are zero-knowledge.
|
||||
- **Local discovery:** mdns-sd crate v0.13.11. Registers `_kon-sync._tcp.local.` via multicast DNS.
|
||||
- **Local discovery:** mdns-sd crate v0.13.11. Registers `_magnotia-sync._tcp.local.` via multicast DNS.
|
||||
- **Device pairing:** QR code + Noise XX handshake (snow crate v0.9.x) with OTP pre-shared key. No server required.
|
||||
- **Relay fallback:** Self-host with `cargo install iroh-relay` on a £4/month VPS. n0 also operates free public relays (rate-limited).
|
||||
- **Conflict resolution:** Last-Writer-Wins per field (highest lamport timestamp, site_id tiebreaker). Edits to different fields merge cleanly. Extended offline: changeset size proportional to number of changes, not duration.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Building Kon: a complete technology map for local-first, voice-first desktop AI
|
||||
# Building Magnotia: a complete technology map for local-first, voice-first desktop AI
|
||||
|
||||
**Kon's entire stack -- from audio capture through LLM inference to neurodivergent-friendly UI -- can be built from actively maintained, production-tested open-source components.** The Rust + Tauri v2 + Svelte 5 ecosystem has matured dramatically through 2024-2026, with reference applications like Handy (13.8k stars, Tauri + Whisper + real-time audio) and Whispering (Svelte 5 + Tauri transcription) proving the core architecture viable. The most critical finding: **no existing app combines all of Kon's pieces**, making this a genuinely novel integration -- but every individual subsystem has battle-tested implementations to learn from.
|
||||
**Magnotia's entire stack -- from audio capture through LLM inference to neurodivergent-friendly UI -- can be built from actively maintained, production-tested open-source components.** The Rust + Tauri v2 + Svelte 5 ecosystem has matured dramatically through 2024-2026, with reference applications like Handy (13.8k stars, Tauri + Whisper + real-time audio) and Whispering (Svelte 5 + Tauri transcription) proving the core architecture viable. The most critical finding: **no existing app combines all of Magnotia's pieces**, making this a genuinely novel integration -- but every individual subsystem has battle-tested implementations to learn from.
|
||||
|
||||
**Ingested from:** `input/inbox/backlinksforfree` on 2026/03/20
|
||||
**Used in:** `docs/superpowers/specs/2026-03-20-kon-mvp-design.md`
|
||||
**Used in:** `docs/superpowers/specs/2026-03-20-magnotia-mvp-design.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +43,7 @@ Model lifecycle: load at first inference, keep during session, unload on backgro
|
||||
|
||||
Hybrid search: FTS5 + sqlite-vec with **Reciprocal Rank Fusion** (documented by Alex Garcia). <3ms total retrieval on Raspberry Pi Zero 2 W.
|
||||
|
||||
**No published project combines sqlite-vec + fastembed-rs** -- Kon's implementation is novel.
|
||||
**No published project combines sqlite-vec + fastembed-rs** -- Magnotia's implementation is novel.
|
||||
|
||||
### 5. Time-block visualisation
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Tiimo Competitive Intelligence Report (2026)
|
||||
|
||||
## Executive Summary: Kon's Key Advantages
|
||||
Based on current intelligence, **Kon** has several immediate strategic openings against Tiimo:
|
||||
1. **The "Lifetime" Opening:** Tiimo recently removed their highly popular lifetime license, causing massive frustration in the neurodivergent community (who often struggle with recurring subscriptions). Kon can win significant goodwill by offering a clear, sustainable lifetime tier or a radically different neuro-friendly pricing model.
|
||||
2. **The Android/Platform Gap:** In September 2025, Tiimo completely removed its Android app, leaving a massive portion of the market unserved. They also lack a true native desktop application (relying on a web wrapper). Kon's native desktop-first approach fills a vital gap for users who need deep workflow integration rather than just a mobile companion.
|
||||
3. **The Complexity Friction:** While Tiimo's AI Co-planner is popular, users report a steep learning curve and heavy setup time. Kon's voice-transcription premise—allowing users to simply speak to create structure—offers a dramatically lower barrier to entry for users with executive dysfunction.
|
||||
## Executive Summary: Magnotia's Key Advantages
|
||||
Based on current intelligence, **Magnotia** has several immediate strategic openings against Tiimo:
|
||||
1. **The "Lifetime" Opening:** Tiimo recently removed their highly popular lifetime license, causing massive frustration in the neurodivergent community (who often struggle with recurring subscriptions). Magnotia can win significant goodwill by offering a clear, sustainable lifetime tier or a radically different neuro-friendly pricing model.
|
||||
2. **The Android/Platform Gap:** In September 2025, Tiimo completely removed its Android app, leaving a massive portion of the market unserved. They also lack a true native desktop application (relying on a web wrapper). Magnotia's native desktop-first approach fills a vital gap for users who need deep workflow integration rather than just a mobile companion.
|
||||
3. **The Complexity Friction:** While Tiimo's AI Co-planner is popular, users report a steep learning curve and heavy setup time. Magnotia's voice-transcription premise—allowing users to simply speak to create structure—offers a dramatically lower barrier to entry for users with executive dysfunction.
|
||||
4. **B2B / Teams Vacuum:** Tiimo has virtually no enterprise or team-based pricing, focusing entirely on solo consumers (and a 5-person "family" sharing plan). This leaves the B2B neurodiversity-inclusion workspace wide open.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §12 Live User Sentiment -->
|
||||
<!-- Source: Magnotia Master Brief — §12 Live User Sentiment -->
|
||||
|
||||
## 12. Live User Sentiment — What Neurodivergent Users Actually Say
|
||||
|
||||
@@ -17,7 +17,7 @@ The dominant emotional narrative across every neurodivergent community: download
|
||||
### Emotional intensity
|
||||
Language consistently involves shame ("another thing I'm failing at"), resignation ("I've lost count"), and liberation when users find the right framing ("I wasn't broken — I was working with tools designed for someone else's operating system"). Anger directed specifically at subscription billing: one Effecto review reads "Pretty ironic that it's an app supposed to be ADHD-friendly yet charges you for a service you don't use." A Wisey Trustpilot review states: "They are unscrupulous and taking advantage of people with ADHD who may be less organised."
|
||||
|
||||
### Demand signals for Kon's specific features
|
||||
### Demand signals for Magnotia's specific features
|
||||
- **Voice-first capture** receives consistent praise wherever it appears — one user who deleted 47 apps kept a voice memo tool as one of three survivors.
|
||||
- **Offline/local-first** positioning is an emerging differentiator; community responds positively to "your data stays with you."
|
||||
- **One-time purchase preference** is acute: a Goblin Tools App Store reviewer wrote "The fact it isn't subscription-based is incredibly helpful — I know it's mine and can use it whenever I need, without having to worry about whether it's 'worth it' each month or if I'm going to forget to cancel."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- Source: Kon Master Brief — §1 What Kon Is -->
|
||||
<!-- Source: Magnotia Master Brief — §1 What Magnotia Is -->
|
||||
|
||||
## 1. What Kon Is
|
||||
## 1. What Magnotia Is
|
||||
|
||||
A voice-first productivity app for people with executive dysfunction, neurodivergence, and task paralysis. Users brain dump via voice, Kon transcribes locally using AI, and automatically organises thoughts into actionable task lists.
|
||||
A voice-first productivity app for people with executive dysfunction, neurodivergence, and task paralysis. Users brain dump via voice, Magnotia transcribes locally using AI, and automatically organises thoughts into actionable task lists.
|
||||
|
||||
**Core thesis:** Capture thoughts the instant they appear, with zero friction, zero latency, and total privacy. Everything runs on-device. No cloud dependency, no subscriptions for core features, no data leaves the user's machine.
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Source: Kon Master Brief — §14 Why Current Tools Fail -->
|
||||
<!-- Source: Magnotia Master Brief — §14 Why Current Tools Fail -->
|
||||
|
||||
## 14. Why Current Tools Fail
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
name: Code Review — 2026/04/22
|
||||
description: Full-sweep audit findings across all Kon crates + src-tauri, with triage buckets for quick wins vs release-blockers
|
||||
description: Full-sweep audit findings across all Magnotia crates + src-tauri, with triage buckets for quick wins vs release-blockers
|
||||
type: reference
|
||||
tags: [code-review, audit, bugs, kon, release-blockers]
|
||||
tags: [code-review, audit, bugs, magnotia, release-blockers]
|
||||
date: 2026/04/22
|
||||
---
|
||||
|
||||
# Kon Code Review — 2026/04/22
|
||||
# Magnotia Code Review — 2026/04/22
|
||||
|
||||
Full-sweep read-only audit of every `.rs` file across the Kon workspace. Four parallel Codex agents scanned:
|
||||
Full-sweep read-only audit of every `.rs` file across the Magnotia workspace. Four parallel Codex agents scanned:
|
||||
- **Agent A** — `crates/transcription/`, `crates/audio/`
|
||||
- **Agent B** — `crates/ai-formatting/`, `crates/llm/`, `crates/storage/`
|
||||
- **Agent C** — `src-tauri/src/` (commands layer + lib.rs + main.rs + types.rs)
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
name: dev-setup
|
||||
type: reference
|
||||
tags: [setup, dependencies, build, linux, fedora]
|
||||
description: Authoritative build dependencies and launch instructions for Kon on Fedora Linux
|
||||
description: Authoritative build dependencies and launch instructions for Magnotia on Fedora Linux
|
||||
---
|
||||
|
||||
# Kon — Developer Setup
|
||||
# Magnotia — Developer Setup
|
||||
|
||||
Last updated: 2026/04/18. Primary dev target: Fedora 43, x86_64, KDE Wayland, NVIDIA RTX 4070.
|
||||
|
||||
@@ -67,7 +67,7 @@ Rust toolchain managed by `rustup`. No extra steps needed beyond what Tauri requ
|
||||
### CPU build (default)
|
||||
|
||||
```bash
|
||||
cd /home/jake/Documents/CORBEL-Projects/kon
|
||||
cd /home/jake/Documents/CORBEL-Projects/magnotia
|
||||
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev
|
||||
```
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user