Spins up `npm run dev:frontend` (Vite without Tauri), drives Playwright
Chromium at 1440x900, and writes a PNG per UI surface to
/home/jake/lumotia-v0.2-screenshots/.
Surfaces captured (16 total):
01 Dictation (default)
02 Files
03 Tasks
04 History
05 Settings
06 Dictation × dark/light × cave/energy/reset (6 surface sets)
07 /float — frame-less task panel
08 /viewer — transcript viewer
09 /preview — Wayland-hardened transcription overlay
10 /design-system-v2 — internal primitives showcase
The design-system-v2 route is gated by VITE_LUMOTIA_DESIGN_SYSTEM_V2=1
per Phase 5; the screenshot run picks this up via a local-only
`.env.local` (now gitignored). shootRoute() waits on a Lumotia
text node before screenshotting so the 11-primitive showcase has time
to hydrate.
Browser-preview-only console noise ("transformCallback") is expected:
some Tauri-only code paths still call invoke() during initial render
even with hasTauriRuntime() gates upstream. No effect on the
captured surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cargo fmt --check ✓
cargo clippy --workspace --all-targets -- -D warnings ✓
cargo test --workspace ✓
cargo nextest run --workspace ✓ 435/435
npm run check ✓ 0/0/5704 files
npm test ✓ 13/13 (2 files)
npm run test:browser ✓ 3/3 in Chromium
npm run test:e2e ✓ 16/16 × 2 viewports
npm run analyze ✓ reports/bundle-stats.html (1.7 MB)
scripts/dogfood-rebrand-drill.sh ✓ 8/8 (sandbox)
npm run guard:no-skeleton ✓ clean
Two small Phase 8 corrections landed alongside the gate:
vite.config.js: the jsdom suite was picking up
`src/lib/ui/*.browser.test.ts`, which only works under the
@vitest/browser-playwright provider. Added the browser-suffix glob to
the jsdom suite's exclude list so the two runners stop double-running
the same files.
playwright.config.ts: bumped the global `expect.timeout` to 15 s. The
cold first-compile of the Vite SPA tree was exceeding Playwright's
default 5 s `toBeVisible` timeout on the 900x700 project on dogfood
runs. The 1440x900 project (which runs second after a warm cache)
never hit it.
Phase 8 closes Phase-7 page migrations. Branch is ready for the
finishing-a-development-branch handoff.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Combined commit for the three secondary windows. Each +layout@.svelte
was already migrated in Phase 3 KI-05 (the legacy theme-sync $effect
was deleted). The +page.svelte content for each window is explicitly
bespoke per docs/release/v0.2-frontend-overhaul.md §6.3:
/float — Lumotia-To-do panel: list pills, drag-and-drop between
lists, context menus, pin-on-top, custom titlebar drag
region. No Card/EmptyState/Toggle wrappers apply.
/viewer — Transcript viewer with audio player, segment scrubbing,
speaker labels. Bionic-reading action + per-region
accessibility typography are load-bearing. No wrappers.
/preview — Wayland-hardened transcription preview overlay.
WindowTypeHint::Utility, never steals focus, hidden
from Alt+Tab. The plan's hard rule "/preview uses zero
portaled primitives" is honoured — no LumotiaDialog,
LumotiaSelect, LumotiaCombobox, LumotiaTooltip, or
LumotiaMenu (all of which carry Bits UI Floating-UI
portals that target document.body).
Phase 7 closes here. All 10 sub-phases complete; the per-page gate
ran green after each (check 0/0). Full Phase 8 gate fires next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SettingsPage is 2 791 LOC with ~95 form controls. The plan called for
section-by-section migration with selective Formsnap; the wrapper
aliases land Phase 4 made that unnecessary on the per-section level.
Because LumotiaCard / LumotiaToggle / LumotiaSettingsGroup /
LumotiaStatusPill keep the exact prop API of the underlying components,
all that's needed to migrate every existing markup site is repointing
the four local import names:
Card → $lib/ui/LumotiaCard.svelte
Toggle → $lib/ui/LumotiaToggle.svelte
SettingsGroup → $lib/ui/LumotiaSettingsGroup.svelte
StatusPill → $lib/ui/LumotiaStatusPill.svelte
The 90+ <Card>, <Toggle>, <SettingsGroup>, <StatusPill> usages compile
unchanged because the local symbols still resolve to compatible
components. Existing IA is preserved verbatim — section ordering,
SegmentedButton bindings, HotkeyRecorder, ZonePicker, ModelDownloader,
and the Phase 3 KI-05 theme bindings all stay in place.
Formsnap is intentionally NOT pulled into SettingsPage in v0.2. The
form here is a wide tree of independent settings; Superforms +
Formsnap would force a heavyweight schema layer for no observable
validation win.
Per-page gate: npm run check (0/0/5704 files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This is the page Settings will inherit grammar from, so it's worth a
slightly fuller sweep than the earlier ones — without touching the
recording state machine, the SVG VisualTimer, the waveform bars, the
transcript textarea, ModelDownloader, or SpeakerButton, which are all
bespoke per the §6.3 do-not-wrap list.
- StatusPill import → LumotiaStatusPill (all use sites swapped)
- PostCaptureCard import → LumotiaPostCaptureCard
- Card import → LumotiaCard
- EmptyState import → LumotiaEmptyState
- LumotiaNotice import added; the inline `liveWarning` panel now
uses LumotiaNotice tone=caution
The danger-tinted error block (lines ~1130) was left verbatim — it
already nests a StatusPill (now LumotiaStatusPill), a <details>
disclosure, and a Dismiss button in a structure LumotiaNotice's
single-icon contract doesn't model cleanly. Phase 7's primary
output for this page is import-level coherence; behavioural
identity stays untouched.
Bespoke surfaces preserved: recording controls, VisualTimer, waveform,
transcript surface (bionic action + accessibility typography), all
hotkey wiring, all live-session state.
Per-page gate: npm run check (0/0/5704 files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Targeted migration on a 1 225-LOC page. The FTS5 search input stays a
plain <input> (LumotiaCombobox needs an options list; free-text search
doesn't fit the API cleanly enough to justify a rewrite for v0.2).
Row patterns + clear-all modal stay verbatim — their bespoke ARIA and
inline arm-confirm state are core to the page's identity.
- Card import → LumotiaCard (4 use sites bulk-swapped)
- EmptyState import → LumotiaEmptyState (4 use sites)
- LumotiaButton import added for selective use in follow-up sweeps
Bespoke surfaces left verbatim: VirtualSegmentList, audio player,
clear-all type-the-word modal, tag-chip filter bar.
Per-page gate: npm run check (0/0/5704 files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrated the two ad-hoc buttons to wrappers; everything else (display-
only reflective copy, the open-loops list, the Newport shutdown
template) stays verbatim since the page is intentionally low-grammar.
- Back-arrow button → LumotiaIconButton (icon=ArrowLeft, size=sm)
- "Close" button → LumotiaButton variant=primary
Bespoke: none on this page (no recording state, no transcript surface).
Per-page gate: npm run check (0/0/5704 files). Vitest / browser-mode /
e2e baselines stay green (no behaviour change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
src/routes/+layout.svelte was 537 LOC of mixed runtime, chrome and
overlay concerns. Split into three single-purpose shells under
src/lib/shell/, with +layout.svelte reduced to ~28 LOC of pure
composition.
AppRuntime (no DOM beyond <svelte:window>):
- Global hotkey dual backend (evdev / tauri-plugin-global-shortcut)
- 120ms hotkey debounce (sacred behaviour §5 #2)
- PREFERENCES_CHANGED_EVENT listener (sacred §5 #4)
- KI-05 one-shot legacy-theme migration
- Sidebar hotkeys: [ toggle, Ctrl+K, Ctrl+, (sacred §5 #10)
- Wind-down tray listener
- Meeting auto-capture poller
- Global frontend error capture
- Nudge bus + implementation intentions lifecycle
- Font-size CSS var $effect
- Window resize → sidebar auto-collapse
- Onboarding/first-run check + update check + LLM status warm-up
AppChrome (the visual shell):
- Titlebar (OS-aware via customChrome helper)
- Sidebar (recording-state-aware — sacred §5 #1 stays in
Sidebar.svelte verbatim)
- Main slot
- TaskSidebar conditional rail
AppOverlays (mounted-once globals):
- ToastViewport
- FocusTimer
- MorningTriageModal
- ResizeHandles (OS-gated)
src/lib/utils/customChrome.svelte.ts holds the single source of truth
for useCustomChrome, a module-level $state both AppChrome and
AppOverlays subscribe to. Each only ever sees one loadOsInfo() call
between them.
Secondary windows still escape via their own +layout@.svelte; the
defensive isSecondaryWindow check in +layout.svelte stays so a
direct /float, /viewer, /preview navigation through the root layout
also drops the chrome.
Phase 6 per-page gate green: npm run check (0/0/5704 files),
npm test, npm run test:browser (3/3), npm run test:e2e (16/16).
No regressions in the Phase 1 smoke baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six thin alias wrappers, same prop APIs as the underlying components.
Lets pages migrate imports from $lib/components/* to $lib/ui/* one
file at a time without touching markup, and gives Phase 5+ a place
to tighten grammar without churning every call site.
LumotiaCard → Card.svelte
LumotiaStatusPill → StatusPill.svelte
LumotiaToggle → Toggle.svelte (forwards bind:checked, bind:loading)
LumotiaSettingsGroup → SettingsGroup.svelte (typed Props for svelte-check)
LumotiaEmptyState → EmptyState.svelte
LumotiaPostCaptureCard → PostCaptureCard.svelte
Per the plan, the underlying components in src/lib/components/ are
untouched. They get retired during the per-page migrations in Phase 7
once no consumer remains.
LumotiaSettingsGroup mirrors the underlying Props interface explicitly
because Svelte 5's spread-into-typed-component caught a real missing-
`title` error during svelte-check. The mirrored interface keeps call
sites type-safe when importing via $lib/ui/.
Phase 4 per-phase gate green: npm run check (0/0/4135 files),
npm test (all green), npm run test:e2e (16/16), npm run
guard:no-skeleton (clean).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive token grammar (no renames, no replacements):
- --color-caution (dark #e8be4a, light #a08a1f) becomes the canonical
name for tuned-amber notice surfaces in the new wrapper grammar
- --color-warning is kept as a CSS var() alias of --color-caution so
every existing text-warning / bg-warning call site stays valid
- --color-info (dark #7a9ec0, light #3d6a8a) is the soft blue-grey
signal for the new LumotiaNotice info variant
- --color-accent-environment (dark #8fae9a, light #4a7058) is an
optional sage/moss support token for empty-state illustrations
and environment-neutral status dots. NOT a brand swap — amber/
copper --color-accent stays primary
Mirrored in src/design-system/colors_and_type.css (the buildless
preview pages bypass Tailwind so the duplication is intentional).
KI-05 resolved in the same commit, per the plan:
- src/lib/types/app.ts: drop `theme` from SettingsState
- src/lib/stores/page.svelte.ts: drop `theme: "Dark"` from defaults
- src/routes/+layout.svelte: drop the migration $effect, add a
one-shot migrateLegacyTheme() on mount that copies any historical
lumotia_settings.theme into preferences.theme and strips the
legacy field. Idempotent — subsequent loads short-circuit
- src/routes/{float,viewer,preview}/+layout@.svelte: drop the same
$effect; secondary windows inherit theme via PREFERENCES_CHANGED_EVENT
- src/lib/pages/SettingsPage.svelte: both SegmentedButton bindings
(quick-settings row at :1272, Appearance group at :2606) now use
Svelte 5 function bindings ({ get, set }) backed by prefs.theme
and updatePreferences. No SegmentedButton API change
Phase 3 per-page gate green: npm run check (0/0/4129 files),
npm test (13/13), npm run test:e2e (16/16). No regressions in
the Phase 1 smoke baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exact-pinned per the plan:
bits-ui@2.18.1 — headless Svelte 5 primitives
formsnap@2.0.1 — field+label+error wrapper
sveltekit-superforms@2.30.1 — form state + validation
zod@4.4.3 — schema validation (superforms accepts ^3.25 || ^4)
@internationalized/date@3.12.1 — bits-ui peer dep
Phase 2 gate green: npm audit signatures (273 verified registry sigs +
93 attestations), npm run check (clean), npm test (clean), npm run
test:e2e (16/16). No regressions in the Phase 1 smoke baseline.
@chenglou/pretext audit (plan risk item): kept — still referenced in
src/lib/utils/textMeasure.ts and src/lib/shims.d.ts.
No primitives wired yet — that's Phase 5. This commit only puts the
headless layer on disk so Phase 4/5 can import from it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0 — docs/release/v0.2-frontend-overhaul.md as single source of
truth for the v0.2 frontend coherence pass. Records hard rules,
tooling pins, sacred-behaviour contract list, wrapper catalogue,
per-page migration order, verification matrix, KI-05 plan, and the
explicit "DO NOT add Skeleton" line.
Phase 1 — tooling baseline. All exact-pinned per the plan:
- @playwright/test@1.60.0 + playwright@1.60.0 + @axe-core/playwright@4.11.3
- rollup-plugin-visualizer@7.0.1 (wired behind ANALYZE=1)
- @vitest/browser@4.1.6 + @vitest/browser-playwright@4.1.6 (provider)
- vitest-browser-svelte@2.1.1 (runes-aware Svelte 5 bridge)
- cargo-nextest installed globally
New configs: playwright.config.ts (frontend-only, dev:frontend webServer,
900x700 + 1440x900 projects, visual baselines deferred), vitest.browser.config.js
(separate from jsdom suite). New scripts: test:e2e, test:e2e:ui,
test:browser, analyze, test:rust:fast, guard:no-skeleton.
guard-no-skeleton.mjs walks package.json + package-lock + src/ for any
@skeletonlabs reference and exits 1 if found — locks in the no-Skeleton
hard rule for any future agent.
Smoke baseline (tests/e2e/smoke.spec.ts): 16 tests passing across two
viewports — app loads without Tauri runtime, keyboard nav, axe scan
(color-contrast deferred to Phase 7 per docs/release/v0.1-contrast-audit.md),
light/dark theme cycle, all three sensory zones (cave/energy/reset).
10 screenshots emitted to test-results/ as non-failing artefacts.
Surfaced + fixed two browser-preview bugs while landing the baseline:
- src/lib/utils/osInfo.ts: FALLBACK_BROWSER_INFO was evaluated at SSR
module-load (navigator undefined → os: 'unknown'), and the UA check
ran before navigator.platform — so Playwright's Windows-UA Chromium
on a Linux runner detected as Windows, useCustomChrome went true,
Titlebar mounted and tripped Tauri-only APIs. Now lazy-built per
call; platform is the primary signal, UA only a fallback.
- src/lib/components/Titlebar.svelte: defensive hasTauriRuntime() guard
on every handler and the $effect. Titlebar should not crash if any
future code path mounts it without Tauri.
.gitignore: reports/, .playwright/, test-results/, playwright-report/.
Per-phase gate green: npm run check (0/0), npm test (0/0), npm run
test:e2e (16/16), npm run guard:no-skeleton (clean).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operationalises the ChatGPT review of the v0.1 release-doc set into two
related landings. Both bounded; no Garden Inbox work, no architecture
refactor, no full redesign.
PASS 1 — v0.1-checklist.md refinements
=======================================
Seven targeted edits to the existing checklist:
1. Cold setup vs warm activation split. Tester acceptance test was
conflating model-download time (variable, network-dependent) with
UX-controlled flow time. Two-phase pass:
- Cold setup: install → onboarding → ready-to-record (no time bound)
- Warm activation: model-ready → first recording within 3 minutes
Steps 1-4 are cold; 5-10 are warm.
2. Migration-aware onboarding wording. "Skip-onboarding path for users
who already have transcripts on disk" → "Migration-aware onboarding:
existing users with valid data are not forced through first-run, but
can launch the tutorial manually from Settings → Help."
3. UI acceptance section. New testable items between Documentation
surface and Quality gates — turns "redo the UI" into measurable
requirements:
- Main capture action visible <1s on Home
- Recording state not communicated by colour alone
- 10-step flow completable at 900×700 + keyboard only
- Destructive actions reversible or confirmed
- Every async state has sidebar status chip feedback
- Error states preserve raw transcript + plain-words next step
- Settings Start Here / Privacy / Accessibility findable
- Focus ring visible everywhere
- prefers-reduced-motion respected
- WCAG AA contrast spot-check both modes
- Post-capture card surfaces (display-only; NOT Garden Inbox)
4. Supported platforms scope. New subsection before smoke-test matrix
explicitly naming:
- Primary (must work end-to-end): Linux Fedora + Ubuntu LTS
- Best-effort (announced if smoke-tested): macOS Apple Silicon,
Windows 11
- Not announced unless smoke-tested: macOS Intel
5. P0/P1/P2 smoke-test severity replacing "any ❌ blocks tag":
- P0 — blocks tag (tester spine on a primary platform)
- P1 — ships only with explicit known-limitation entry
- P2 — does not block private beta (not-announced platform / v0.2
feature)
Pre-tag verification confirms no unresolved P0 or undocumented P1.
6. "Telemetry" → "local activation log". Re-worded the activation
metrics capture mechanism. Word choice deliberate — privacy-conscious
audience reacts to "telemetry" itself. Surface: Settings →
Diagnostics → Activation log. Nothing sent automatically.
7. Support burden signal. New activation-metric subsection covering
the AI-assisted-indie risk that every issue becomes a support call:
- Self-service rate ≥ 70% (issues filed to bug tracker, not inbox)
- Diagnostic bundle (logs + system info + crash dumps; skips
transcript content + audio by default)
- Top-3 setup failures documented after first 5 testers
PASS 2 — v0.1-ui-hardening.md
==============================
New strict-boundary doc at docs/release/v0.1-ui-hardening.md (262
lines). Anchored on the line:
The v0.1 UI pass is not there to make Lumotia beautiful. It is there
to make the first successful capture inevitable.
Step 0 (before any code change): walk the 20 existing
src/design-system/preview/ files and classify each item as
already-good / needs-v0.1-hardening / v0.2-polish. Don't rebuild what
works. Inventory table inline in the doc cross-references each preview
file to the in-scope items below.
IN SCOPE (10 items, each testable):
1. Home capture clarity — big record button, status pill, last-capture
preview, capped Now/Tasks at 1-3 visible
2. Recording as sacred UI state — hide settings/history/advanced
during capture; show only timer/pause/stop/cancel + live transcript
+ level meter
3. Post-capture card — the v0.1 headline UI artefact. Display-only
surface of raw + cleaned + tasks + microsteps + 4 actions
(Save/Export/StartFirstMicroStep/OpenInHistory). Explicitly NOT
Garden Inbox: no routing, no accept/edit/park/archive, no
backlinks, no confidence scores
4. First-run onboarding polish — single clear next action per step,
pre-supplied prompt for test recording, graceful failure recovery,
skip-to-main escape hatch (tracked as known-limitations follow-up)
5. Settings sanity pass — 6 sections in order: Start Here /
Transcription / Models / Tasks / Accessibility / Privacy / Advanced.
Full 7-group progressive-disclosure regroup deferred to v0.2
6. Error-state copy sweep — every error preserves raw transcript,
explains in plain words, says next user action, no stack traces
user-facing
7. Keyboard flow — entire 10-step tester acceptance completable by
keyboard only, focus ring visible, no hover-only controls
8. Responsive at 900×700 + 1440×900 ONLY — ultrawide / mobile / split-
screen deferred to v0.2 unless a tester reports them
9. Accessibility practical checks (WCAG-style, not certification) —
keyboard, focus, not-colour-alone, reduced motion, contrast spot-
check, literal-words status labels, form-label association
10. Status labels everywhere — new StatusPill component (no existing
class found in survey); add to design-system/preview/components-
status-pills.html. Pill states: Ready / Recording / Paused /
Transcribing / Cleaning / Extracting tasks / Saved / Exported /
Needs review / Failed safely
OUT OF SCOPE (the traps to refuse — each ships in v0.2 or later):
- New visual identity (brand book v3 PDF is locked)
- New navigation model
- Garden Inbox (review cards, routing, accept/edit/park/archive,
related notes, backlinks, P-P-T detection)
- Suggested routing
- Backlinks
- Graph view
- Canvas view
- New animation system
- Full SettingsPage 7-group redesign
- Obsidian plugin
- Cloud / provider UI
Plus a "Definition of done" with 8 specific completion criteria, and
cross-references to checklist + Garden roadmap + how-built + design-
system preview + locked brand book.
VERIFICATION
============
- cargo fmt --check: clean (no Rust touched)
- All four release docs cross-reference cleanly
- No new tests required (boundary docs, not code)
- v0.1 ship gate unchanged in shape, sharpened in detail
Operationalises the ChatGPT/Jake roadmap-synthesis pass into four
release-boundary documents at docs/release/. Synthesis call:
v0.1 = stable local capture product
v0.2 = Garden Inbox / review cards
v1.0 = PKM-complete + commercial track
Two factual audits ran first per Jake's explicit instruction — release
hardening only, no architecture refactors, no Garden Inbox work:
AUDIT 1 — MCP surface
=====================
Verdict: PASSES the v0.1 trust posture.
- Read-only by design (`//! No writes — Lumotia's Tauri app remains the only writer`)
- Stdio-only transport (newline-delimited JSON-RPC 2.0); no TCP/Unix
listener, no bind, no network exposure
- Database opened via `lumotia_storage::init_readonly` — structurally
enforced, not just convention
- 4 tools, all SELECT-only: list_transcripts, get_transcript,
search_transcripts, list_tasks
- Zero matches for INSERT/UPDATE/DELETE/fs::write/fs::remove/
create_dir/spawn_blocking in the crate
- Separate binary (`crates/mcp/src/main.rs`) — not part of the running
Tauri app; user must explicitly launch and wire into client config
- Honest nuance flagged in known-limitations: a wired client gets read
access to the entire transcript history + task list — no per-row
permission boundary in v0.1
AUDIT 2 — LLM failure surface
=============================
Verdict: data-loss path PASSES; UX-wedge path PARTIAL (documented).
- post_process_segments (file + live pipeline): tracing::warn! on Err,
segments stay at rule-based output. Raw transcript preserved.
- cleanup_transcript_text_cmd (DictationPage): frontend try/catch
returns raw text unchanged on Err. Raw transcript preserved.
- extract_tasks_from_transcript_cmd (DictationPage): frontend falls
back to rule-based extractTasks (regex + verb list) on Err.
- extract_content_tags_cmd (HistoryPage): per-row try/catch; toast on
failure; transcript untouched.
- Hung llama.cpp: no tokio::time::timeout on the spawn_blocking call.
Raw transcript preserved; rest of app functional; LLM status chip
stays on "Cleaning up" until restart. Soft edge — documented in
known-limitations as v0.2 hygiene candidate. Not implemented per
"release hardening only" instruction.
THE FOUR DOCS
=============
docs/release/v0.1-checklist.md
- 10-step tester acceptance test (install → capture → cleanup →
task → MicroSteps → timer → history search)
- Must-ship list per surface (product, onboarding, artefacts, docs,
quality gates, trust+security, release-blockers, smoke-test
matrix)
- Activation metrics for private beta (3 min to first capture, 3
captures in 24h, 7-day return, etc.) + v0.1 public launch
(20 install, 15 first-capture, 10 return, 5 pay-£39)
- Pre-tag verification sequence
- Explicit out-of-scope list (Garden Inbox, Phases B-E/G/I/J, etc.)
docs/release/v0.1-known-limitations.md
- User-facing rewrite, not engineer-speak
- Power assertions per platform (Linux idle / macOS App Nap / Windows
sleep) with practical workarounds
- MCP read-only/local-only posture with the
"all transcripts visible to your wired client" honest nuance
- AI cleanup/extraction failure table — what fails, what you see,
what's preserved
- Settings page progressive-disclosure status
- Internal engine refactor (orchestrator dormant) framed for users
- Explicit "what's NOT in v0.1" call-outs
- Reporting issues + crash-dump location
docs/release/v0.2-garden-roadmap.md
- Headline: "review cards for turning messy dictations into notes,
tasks, topics and links" — tangible, not PKM-overloaded
- Garden Inbox scope (raw / cleaned / suggested title-type-folder-
project / extracted tasks / suggested tags / possible links /
confidence / Accept-Edit-Park-Archive)
- Engine architecture Phases B-E pairing
- Explicit "NOT in v0.2" list (no graph, no canvas, no PKM marketing,
no cloud provider, no premium voices)
- Open decisions for v0.2 scope freeze deferred until v0.1 ships +
20 testers run
docs/release/how-lumotia-is-built.md
- Public-facing trust page; honest disclosure that AI-assisted
human-directed, then evidence
- The real silent-data-loss bug the drill caught (Phase A.7 fix
ff8dda0) framed as proof the process works
- Phase B atomiser audit: 9 surgical fixes including the FIFO hang,
LlmEngine unload race, purge-vs-restore SELECT-then-DELETE race
- Supply-chain pre-flight (npm audit signatures + --ignore-scripts +
pinned dev deps + pinned rust toolchain)
- MCP read-only audit + LLM failure audit cross-referenced
- Anti-patterns explicitly avoided (no telemetry exfiltration, no
silent AI dependency, no "audit log later", etc.)
- Calibrated to "AI use is survivable; sloppy undisclosed untested
AI use is not" — RPCS3 framing cited
Verification:
- cargo fmt --check: clean (no Rust changed)
- All four docs are user-readable, not commit-log-derivative
- Cross-references resolve (every internal path quoted exists)
Records the per-item verdict + commit hash for every Phase B audit item
(B.2 through B.15). Status block flipped to Complete 2026/05/14. Items
table moved every row from Pending → Done or Documented pass with a
one-paragraph outcome summary. Each Done item also has a full
section in the Done items list, mirroring B.1's existing structure
(surface, why it matters, fix, verification).
Surgical commits in the audit pass:
643985d B.2 supervisor doc + test name match detach semantics
31e3f5a B.3 download_impl unlinks .part on ResumeUnsupported
20ef6c4 B.4 atomic DELETE RETURNING in purge_deleted_transcripts
d8fa4ff B.5 resolve_export_path follows symlink before containment
7f0e1b0 B.6 capability JSON mirror invariant pinned
f252c1b B.7 unload() honours the loading flag
813f024 B.8 storage crate emits via tracing (was log crate)
401b6c3 B.9 strip <think>…</think> reasoning before JSON-envelope scan
1c4ac98 B.10 vitest regression for focusTimer expired-rehydrate
Documented passes (no commit, recorded reasoning in plan): B.11, B.12,
B.13, B.14, B.15.
Phase A baseline gates verified green after the audit pass:
cargo test --workspace → 417 / 0 (was 409 baseline, +8 new tests)
cargo fmt --check → clean
cargo clippy --workspace ...→ clean
npm run test → 13 / 13 (was 12, +1 new test)
npm run check → 0 errors / 0 warnings across 4015 files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B.10 audit of commit 5ba761a (focusTimer rehydrate startTick
invariant; Race-10). The commit landed a comment-only change at
src/lib/stores/focusTimer.svelte.ts lines 204-208:
// startTick() is REQUIRED here: tick() is the only thing that
// observes `now >= completionFlashUntil` and calls clear(). Without
// it, the completion flash would stay visible until the user
// interacts with the app. stopTick() runs via clear() once the
// flash window elapses.
A future edit could silently drop the startTick() call (the commit
acknowledged this with the comment) and the regression would only
surface on a real session: the user reopens Lumotia after a closed
expired timer, sees the completion flash, and waits for it to clear.
It never does. They click somewhere → clear() fires from the click
handler. Visible bug, but no automated gate.
The 5ba761a commit could not add a test at the time it landed because
vitest wasn't wired in the workspace — vitest scaffold landed in
commit 206ac62 the next day as Phase A.5. Now that vitest exists
(jsdom environment, .svelte.ts rune transformer, fake timers via
vi.useFakeTimers — see vite.config.js test block), the invariant is
straightforwardly testable.
Fix: new src/lib/stores/focusTimer.test.ts. The single test
`auto-clears the completion flash after the 3s window via the tick loop`:
1. Seeds localStorage with a timer started 60 s ago that lasted only
30 s — already-expired by 30 s when rehydrate runs.
2. Calls focusTimer.rehydrate(). Asserts the flash is now visible
and the timer is reported active.
3. vi.advanceTimersByTime(3_500) — pushes wall-clock past the
completionFlashUntil mark, drives the setInterval ticks.
4. Asserts showingCompletionFlash is false, active is false,
remainingMs is 0, and localStorage has been wiped.
If a future edit removes the startTick() call on the already-expired
branch of rehydrate(), step (3) won't run any ticks, the flash won't
clear, and step (4) fires the regression assertion. The test pins the
invariant the comment alone could not.
Verification:
* npm run test → 13/13 (was 12, +1 from this commit). Both test
files pass: localStorageMigration.test.ts (unchanged, 12 tests)
and the new focusTimer.test.ts (1 test).
* npm run check → 0 errors / 0 warnings across 4015 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B.9 audit of commit 1d71e8e (replace GBNF grammar with manual
brace-counting JSON-envelope extractor). Existing coverage:
* parse_string_array_trims_and_dedupes
* json_envelope_complete_detects_finished_{object,array}
* json_envelope_complete_ignores_braces_inside_strings
* json_envelope_complete_rejects_prefixes_and_trailing_text
* extract_json_envelope_skips_qwen_thinking_prefix (EMPTY think block)
* extract_json_envelope_handles_arrays_and_trailing_stop_text
Solid for the cases tested. One real residual.
The `_skips_qwen_thinking_prefix` regression uses an EMPTY <think></think>
block: `"<think>\n\n</think>\n\n{...}"`. Qwen3.5's reasoning mode emits
non-empty reasoning when enabled (and reasoning is a documented Qwen
feature, surfaced in the model name family the engine targets). The
naive "find the first '{' or '[' in the whole text" extractor breaks in
two ways once the reasoning is non-empty:
1. **JSON-looking text in thinking.** The model thinks out loud about
the schema: "the answer should look like {\"topic\":\"x\",\"intent\":\"y\"}".
The extractor sees the FIRST '{' (inside the reasoning), scans for
its matching '}', and returns the reasoning literal as the
envelope. The actual answer after </think> is dropped.
2. **Unbalanced braces in thinking.** The model writes "I wonder
about {something unfinished" inside <think>. The extractor starts
its brace-stack on that unbalanced '{', never finds a matching
'}', scans past </think> picking up the real answer's '{' (stack
now has TWO '}' targets), eventually finds one '}' which pops the
thinking's, then end of input — returns None. The actual answer
is lost entirely.
Fix: split on the FIRST `</think>` and scan only the substring after.
Anything before `</think>` is reasoning, anything after is the answer
proper. Falls back to the whole text when no `</think>` is present
(covers non-reasoning models AND the empty-thinking case the existing
test pins).
Backwards-compatible:
* Empty thinking — split_once returns ("", "\n\n{...}"); scan
finds the '{' and returns the answer. Existing test passes.
* No thinking tags at all — split_once returns None; fall back to
full text. Existing tests pass.
* Trailing stop tokens (`<|im_end|>` etc.) — unchanged behaviour;
they sit after the envelope and don't affect the scan.
New regression tests:
* extract_json_envelope_skips_thinking_block_with_json_looking_content
— thinking with a JSON literal followed by the real answer. Pre-fix
would return the thinking's literal; post-fix returns the answer.
* extract_json_envelope_survives_unbalanced_braces_in_thinking — the
unbalanced-brace-in-thinking case. Pre-fix returns None; post-fix
returns the real answer.
Verification:
* cargo test -p lumotia-llm --lib
→ 28/28 pass including the two new tests.
* cargo fmt --check → clean.
* cargo clippy -p lumotia-llm --all-targets -- -D warnings → clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B.8 audit of commits 65abfa2 (Obs-3, span propagation across
spawn boundaries), 8becb1a (audit-trail empty commit for Obs-4/5
absorbed into afbd33d), and d1391b3 (Obs-1/2, drop lumotia_live literal
target). Existing coverage:
* commands::live::tests::no_lumotia_live_literal_target_in_live_rs
pins Obs-1/2 — no literal lumotia_live target survives.
* src-tauri/tests/tracing_appender_smoke.rs::init_tracing_creates_log_file
pins Obs-4/5 — install_subscriber writes to a rolling lumotia.log.
* Obs-3 (span propagation) is not directly tested. Verifying that
`tokio::spawn` / `thread::spawn` children carry the parent span would
require custom subscriber infrastructure; the commit message
acknowledges the 4 instrumented sites as canonical correlation
points + "everything else fans out from them" + "Storage/audio/
hotkey/MCP crates left uninstrumented in this commit — future
sweep". Honour the SAFETY-style annotation; do not chase a synthetic
subscriber test.
One real residual found.
DEFAULT_STDERR_FILTER and DEFAULT_FILE_FILTER in src-tauri/src/lib.rs
both list `lumotia_storage=info` (stderr) / `lumotia_storage=debug`
(file). Operator intent: storage events surface in stderr AND in the
rolling lumotia.log forensic stream that diagnostic-report bundles
attach. The reality: every storage event vanishes.
The storage crate uses `log` crate macros (log::warn! / log::info!),
not tracing. src-tauri/src/lib.rs installs a tracing subscriber but
does NOT install a `tracing-log::LogTracer` bridge, so log-crate events
never reach any tracing layer. There is no other log subscriber wired
either, so the events are silently dropped.
Concrete signals missing from diagnostic reports:
* Migration progress (info, lines 603 + 639 in migrations.rs) —
fires on every schema bump on every first-run after upgrade. Used
to confirm "did the user's migration succeed?".
* Audio-cleanup warnings (warn) from delete_transcript (database.rs
line 369) and purge_deleted_transcripts (line 434) — the two
log lines Rev-3 specifically added so a forensic report could
confirm whether disk cleanup completed cleanly.
Same forensic blindness Obs-4/5 fixed for the rest of the codebase,
just for the storage subset.
Fix:
* crates/storage/Cargo.toml: replace `log = "0.4"` with
`tracing = "0.1"`. Every other crate in the workspace already uses
`tracing = "0.1"`; storage was the outlier.
* Replace the 4 `log::*!(target: "lumotia_storage", …)` calls with
`tracing::*!(target: "lumotia_storage", …)`. Targets unchanged.
* Reformat the two migration log lines as structured tracing events
(version + description fields rather than printf-style positional
interpolation) so they're filterable by EnvFilter directives and
machine-readable in the forensic stream.
No behaviour change to storage call semantics. Pure logging-pipeline
rewire.
Verification:
* cargo test -p lumotia-storage --lib
→ 70/70 pass (unchanged — none of the tests depended on the log
crate).
* cargo fmt --check → clean.
* cargo clippy --workspace --all-targets -- -D warnings → clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B.7 audit of commit cde985d (LlmEngine critical-section narrowing
+ drop-old-model-first; Race-3 + Lifecycle-1). Existing coverage is
strong: is_loaded_does_not_block_on_slow_load proves probes return in
< 50 ms while the slow section runs (Race-3); second_concurrent_load_is_refused
proves a parallel load attempt is rejected with EngineError::AlreadyLoading
without reaching the heavy op (Race-3/4 TOCTOU at the engine layer);
the test harness __test_run_with_lock_discipline mirrors load_model_with's
discipline (claim loading flag, clear engine state, run op outside the
inner mutex, then install). The Lifecycle-1 visible side-effect
(is_loaded reports false mid-swap) is covered by the first test.
One real residual found.
unload() does not consult the `loading` flag. When load_model_with is
mid-flight (step 3 has already cleared model + loaded, step 5 has not
yet installed the new state), a concurrent unload() takes the inner
mutex, sees model + loaded already None, no-op-clears, and returns Ok.
The slow load then completes step 5 and installs the new state —
silently overwriting the unload the caller already saw success for.
Concrete attack shape: app startup auto-loads the default LLM in the
background via download_llm_model + load_model. User opens Settings,
clicks "Delete Model X". delete_llm_model checks loaded_model_id()
(returns None mid-load) and skips the unload branch, then calls
model_manager::delete_model(X) which removes the GGUF file from disk.
The load completes via mmap (which on Linux holds the inode alive
after unlink) and installs state pointing at a deleted file path. The
user sees "Model X loaded" in the UI even though they just deleted it.
Same `loading` AtomicBool that guards load-vs-load needs to guard
unload-vs-load.
Fix:
* unload() now checks is_loading() at entry. Returns
EngineError::AlreadyLoading when a load is mid-flight; caller can
retry once is_loading() reports false.
* EngineError::AlreadyLoading message generalised from "refusing to
start a parallel load" to "refusing to start a parallel load or
modify engine state mid-load", since the variant now fires from
both directions. The variant name itself remains accurate (the
state of being already loading).
Behavioural diff for unload during quiescent state: unchanged.
Behavioural diff for unload mid-load: Err(AlreadyLoading) instead of
Ok with silent overwrite.
Callers checked:
* unload_llm_model (Tauri command) — converts EngineError → String
via .map_err and surfaces to the frontend. New error string is
self-explanatory; no frontend code matches on the old message
substring.
* delete_llm_model — calls unload only when loaded_model_id matches.
If unload returns AlreadyLoading the delete also fails;
.map_err(|e| e.to_string())? propagates. The user gets a clear
"cannot unload while loading" toast and can retry; better than the
silent contract-violation the old code allowed.
* No other callers exist for LlmEngine::unload (whisper/parakeet
engines have their own unload methods on a different type).
New regression test: unload_during_load_is_refused. Spins a loader
thread on the existing __test_run_with_lock_discipline harness, blocks
mid-slow-section via a Barrier, fires unload() from the main thread,
asserts AlreadyLoading. After releasing the load, unload() succeeds —
proving the flag-clear discipline on the happy path.
Verification:
* cargo test -p lumotia-llm --lib
→ 26/26 pass including the new test.
* cargo fmt --check → clean (applied fmt after the edit).
* cargo clippy -p lumotia-llm --all-targets -- -D warnings → clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B.6 audit of commits 7aee534 (Trust-3/6 main-window guard + size
cap on clipboard + paste surface), 12b413d (broaden clipboard/paste
allowlist to documented secondary windows), and f7af7b0 (Trust-4 main-
window guard on extract_content_tags_cmd).
Existing coverage is strong:
* commands/security.rs: 4 tests for ensure_main_window_label +
ensure_window_in_set_label accept/reject paths.
* commands/clipboard.rs: 3 size-cap tests via a shadow size_check
helper.
* commands/paste.rs: comprehensive — 4 backend-order, 4
clipboard-restore, 7 terminal-classification, 3 paste-size-cap, plus
paste_cap_matches_clipboard_cap that pins the
MAX_CLIPBOARD_BYTES == MAX_PASTE_BYTES invariant the commit explicitly
cared about.
* commands/llm.rs: extract_content_tags_cmd Trust-4 — unconditional
ensure_main_window guard with no surface to test beyond what's there.
One real residual.
The 12b413d commit message states:
"mirror the secondary-windows capability grant in
src-tauri/capabilities/secondary-windows.json so the IPC trust
boundary and the permission grant stay in lock-step."
But no test pins the mirror invariant. A future change could:
* add a new window to secondary-windows.json and forget to update
CLIPBOARD_ALLOWED_WINDOWS or PASTE_REPLACING_ALLOWED_WINDOWS;
* typo a label in one of the Rust consts;
* remove a window from the JSON while leaving the const intact;
* remove a window from the const while leaving the JSON intact.
Each of those silently drifts the IPC trust boundary against the
capability grant. The two halves stay in lock-step on intent — but the
intent lives only in the commit message and a docstring, not in a
runtime check.
Fix (test-only, no production behaviour change):
* Promote CLIPBOARD_ALLOWED_WINDOWS and PASTE_REPLACING_ALLOWED_WINDOWS
from private to pub(crate) so a single shared test can reference them.
* Cross-reference both consts in a new docstring back to the pinning test.
* Add commands::security::tests_capability_mirror::allowlists_match_capability_jsons.
The test reads capabilities/main.json + capabilities/secondary-windows.json
at CARGO_MANIFEST_DIR, parses with serde_json, collects every label
declared in the "windows" arrays, and asserts every label in both
Rust allowlists is in that declared set.
Asymmetric on purpose: the JSON may legitimately declare windows that
don't need clipboard/paste (e.g. tasks-float doesn't), so the test
does NOT assert const ⊇ JSON, only const ⊆ JSON. The over-restrict
direction is safe; the under-restrict direction is the IPC bypass we
care about.
Verification:
* cargo test -p lumotia --lib commands::security
→ 5/5 pass including the new allowlists_match_capability_jsons.
* cargo fmt --check → clean (applied fmt after the test edit).
* cargo clippy -p lumotia --all-targets -- -D warnings → clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B.5 audit of commits a2b47db/a48653c/b3da58c (Trust-1 — write
path allowlist), 9653e25 (Trust-5 — transcribe_file extension allowlist
+ size cap), and ed449cc (Trust-2 — resolve_recording_path output_folder
validation). The three Trust-1 commits were a corrective sequence that
swapped the staged file in a parallel-agent race; b3da58c is the
authoritative landing.
Existing coverage is strong:
* commands/fs.rs (Trust-1): 6 tests — outside-allowlist, traversal,
accepts inside, accepts nested, rejects missing parent, prefix check.
* commands/transcription.rs (Trust-5): 7 tests — accepts wav,
accepts MP3 case-insensitive, accepts each allowed extension,
rejects unsupported, rejects no-extension, rejects oversize, accepts
exactly-at-cap, rejects traversal-with-disallowed-ext.
* commands/audio.rs (Trust-2): 7 tests including
validate_output_folder_rejects_symlink_pointing_out — the symlink
bypass for output folders is already covered.
One real residual found in commands/fs.rs:
Asymmetric symlink handling between Trust-1 (fs.rs) and Trust-2
(audio.rs). Trust-2 canonicalises the FULL requested path (it's a
directory that must already exist), so a symlink at the directory itself
that points outside the base is resolved before the containment check
and gets rejected. Trust-1 canonicalises only the PARENT of the
requested path, because the target file typically does not exist yet
(canonicalize() returns NotFound on missing paths). Concrete bypass:
1. A symlink at, e.g., ~/Downloads/notes.md -> ~/.bashrc — innocently
created by the user, or planted via another vulnerability.
2. Compromised webview invokes
write_text_file_cmd("/home/user/Downloads/notes.md", "<payload>").
3. Path-scope check: parent canonicalises to /home/user/Downloads,
file_name joins, canonical path string sits inside the Downloads
allowlist. PASS.
4. tokio::fs::write -> File::create -> open(2) follows the symlink and
writes "<payload>" to ~/.bashrc, exfiltrating shell startup.
Fix: two-mode canonicalisation in resolve_export_path. If the target
exists, canonicalise the full path — this follows any symlink at the
target itself, and the subsequent containment check sees the resolved
location. Only on NotFound do we fall back to parent-canonicalise +
join-filename (the original save-dialog path). This mirrors the audio
crate's canonicalisation discipline.
Regression tests:
* rejects_symlink_target_outside_allowlist — creates a symlink inside
a base pointing OUT to a real outside file; resolve_export_path must
return Err with "outside the allowed export directories".
* accepts_symlink_target_inside_allowlist — symmetric, an in-base
alias symlink must still resolve and pass, so legitimate uses of
symlinks are not regressed.
Both gated #[cfg(unix)] because std::os::unix::fs::symlink is unix-only.
The Trust-1 surface ships symmetrically on Windows; the symlink class
attack does not generalise the same way on NTFS (junctions vs symlinks
have different ACL semantics), and a windows-specific test would be
duplicate-effort outside the audit scope.
Verification:
* cargo test -p lumotia --lib commands::fs
→ 8/8 pass including the two new symlink tests.
* cargo fmt --check → clean.
* cargo clippy -p lumotia --all-targets -- -D warnings → clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B.4 audit of commits 15b74db, 87e6248, 50d0715, 99f4ecd (the
soft-delete / trash / restore wave — Rev-2, Rev-3). Existing backend
coverage is solid: migration_v16_adds_deleted_at_column_and_index,
delete_transcript_soft_deletes, delete_transcript_removes_audio_file,
list_transcripts_excludes_soft_deleted (with a restore round-trip),
and purge_deleted_transcripts_hard_deletes_old.
The Svelte UI components added by 87e6248 (Trash view + restore) and
50d0715 (type-the-word DELETE modal) carry TODO(test) notes saying
"vitest not installed". That comment is stale — vitest landed in
Phase A.5 (206ac62). Adding Svelte component tests is real follow-up
work but outside the per-item methodology for B.4; calling it out
here for the Phase-B finishing pass to triage.
One real residual found.
Surface: `purge_deleted_transcripts` in `crates/storage/src/database.rs`.
The prior form was a two-statement SELECT-then-DELETE pair:
1. SELECT id, audio_path FROM transcripts WHERE deleted_at IS NOT NULL
AND deleted_at < datetime('now', '-30 days');
2. DELETE FROM transcripts WHERE id IN (chunk_of_ids);
A `restore_transcript(id)` between (1) and (2) clears `deleted_at` on a
row whose id is in the chunk, but the DELETE has no `deleted_at IS NOT
NULL` filter — so the now-LIVE row is hard-deleted alongside its audio
file. That bypasses the entire Rev-2 soft-delete safety contract: the
user can lose data without the 30-day retention window the contract
promised. In the current code the purge runs once at startup before
the user can issue a restore, so the race window is narrow in
practice. The safety should be structural, not operational —
especially if a future change moves the purge to a daily cron.
Fix: collapse the SELECT + DELETE into a single
`DELETE … RETURNING audio_path`. SQLite (3.35+, well within the
sqlx 0.8 amalgam) evaluates the WHERE clause and the row removal
atomically; the returned `audio_path`s are guaranteed to belong to
rows that THIS call hard-deleted. The audio cleanup loop then operates
on those returned paths, never on rows that survived the WHERE clause.
The chunking concern (IN-clause near SQLITE_MAX_VARIABLE_NUMBER)
disappears too — there is no IN-clause.
Behavioural diff for the non-racing path: identical (same WHERE clause,
same NotFound-tolerant best-effort fs::remove_file).
Behavioural diff for the racing path: a row restored between SELECT and
DELETE survives the purge and keeps its audio file — which is the
contract Rev-2 was added to enforce.
Other surface notes (no fix needed):
* `delete_transcript` is robust to its own concurrent restore — the
UPDATE has `AND deleted_at IS NULL` and audio removal only fires
when `rows_affected() > 0`.
* `restore_transcript` is a single UPDATE — atomic.
* FTS triggers on UPDATE preserve the row in transcripts_fts; the
`t.deleted_at IS NULL` filter on `search_transcripts`'s JOIN keeps
trashed rows out of search results.
New regression test: `purge_audio_cleanup_only_fires_for_hard_deleted_rows`
covers the structural property — an in-retention trashed row with its
audio file on disk survives purge with the audio intact, while a
past-retention trashed row is hard-deleted with audio removed.
Verification:
* cargo test -p lumotia-storage --lib database::tests
→ 53/53 pass including the new test (old purge test still passes).
* cargo fmt --check → clean (applied fmt after the test edit).
* cargo clippy -p lumotia-storage --all-targets -- -D warnings → clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B.3 audit of commit 9f67ab2 (atomic model download + manifest —
Rev-1, Rev-5). Existing coverage is solid: the transcription-side
download_file has fixture tests for resume-and-verify, restart-on-200,
SHA-mismatch cleanup, 5xx rejection, Rev-1 preserve-existing-file, and
the Rev-5 manifest tmp+rename atomicity. The llm-side download_impl
has resume-and-verify and the Rev-1 preserve-existing-file regression.
One real residual found in crates/llm/src/model_manager.rs that the
original commit did not close.
When a stale .part exists (resume_from > 0) and the server returns a
200 full-body response to a Range request, download_impl returns
DownloadError::ResumeUnsupported without unlinking the .part. Every
subsequent download_model() call computes the same resume_from > 0,
sends the same Range request, gets the same 200, and fails the same
way — the download is wedged until the user manually invokes
delete_model(). That is itself a reversibility kill in the same
family as Rev-1: stale partial state stuck on disk, no automatic
recovery, the user has to discover an out-of-band command to escape.
The transcription-side download_file handles this case by treating
200-on-resume as a fresh-start (line 268: "Server ignored our Range
header — treat as fresh start"). The llm-side does not have an
analogous restart code path, but the simpler fix is sufficient: unlink
the .part before returning ResumeUnsupported. The next call sees
resume_from = 0, sends no Range header, the server returns 200, and
download_impl writes the new payload into a fresh .part and renames
atomically over dest. Single retry recovers.
Fix:
* crates/llm/src/model_manager.rs:
- download_impl: tokio::fs::remove_file(&tmp).await.ok() before
returning ResumeUnsupported, with a comment that names this as
a Phase B.3 audit residual and explains the wedge scenario.
- New test resume_unsupported_unlinks_part_so_retry_starts_fresh
— spins a server that ignores Range and returns 200, plants a
sentinel .part, asserts ResumeUnsupported AND .part removed AND
dest not written.
Verification:
* cargo test -p lumotia-llm --lib model_manager
→ 5/5 pass including the new test.
* cargo fmt --check → clean.
* cargo clippy -p lumotia-llm --all-targets -- -D warnings → clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B.2 audit of commit 1068ad9 (hotkey supervisor rearchitecture —
Race-1, Race-2, TOCTOU). The production behaviour is correct and the
integration tests in crates/hotkey/tests/listener_lifecycle.rs cover
Race-1 (per-device listener sender-clone drop on stop) and Race-2
(forwarder join on reconfigure) at the public-API level. The TOCTOU
window is closed by construction (insert-before-spawn under one mutex
hold) and the original author's // TODO(test): note at linux.rs:576
explicitly explains why a deterministic test would require faking
evdev::Device::open, which the crate doesn't expose — honoured.
One real residual: two satellite places in supervisor.rs claim that a
stuck task is "force-aborted" after SHUTDOWN_TIMEOUT, but the code does
NOT abort. `tokio::time::timeout(d, handle).await` consumes the
JoinHandle by value; when the timeout fires, the inner future (the
JoinHandle) is dropped, and dropping a JoinHandle DETACHES the task
rather than aborting it. The shutdown() doc-comment and the warn-log
message both correctly say "detached", but:
* The SHUTDOWN_TIMEOUT const doc-comment said "we give up and abort
it ... force-aborted with a warning".
* The test name was shutdown_force_aborts_stuck_tasks_after_timeout.
A future maintainer trusting either of these would either insert
handle.abort() to make the implementation match (changing shutdown
semantics — abort skips cooperative cleanup) or conclude the doc was
wrong and need to retrace which is authoritative. Same B.1-class hazard
(comment claims one ordering, code does another).
Fix is doc + test-name only:
* SHUTDOWN_TIMEOUT doc-comment now spells out detach-not-abort with
the technical reason.
* Test renamed to shutdown_does_not_block_on_stuck_tasks_after_timeout
with a doc-comment clarifying that the elapsed-bounded assertion is
what guards against a regression that reintroduces an unbounded
handle.await — and that detach behaviour itself cannot be asserted
from the test because register() moves the handle.
No production behaviour change; semantics already correct.
Verification:
* cargo test -p lumotia-hotkey --lib --tests
→ 6 unit + 2 integration = 8/8 pass (unchanged).
* cargo fmt --check → clean.
* cargo clippy -p lumotia-hotkey --all-targets -- -D warnings → clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the 15-item Phase B plan (code-atomiser-fix wave verification) at
docs/superpowers/plans/2026-05-14-phase-b-dogfood-plan.md so the per-item
methodology, status, and findings survive across sessions.
B.1 already done (commit 6c212a0) — full audit trail in the Done items
section: 8 existing unit tests inventory, the misleading start_live
lifecycle comment that was the only real residual, and the documented
pass on the Race-B end-to-end gap per the existing SAFETY annotation.
B.2-B.15 listed with commit references + pending status. Same methodology
per item: orient on commit, survey existing coverage, identify real
residuals, surgical fix or pass, commit. Anti-patterns section captures
the decisions made on B.1 so future me does not re-litigate them.
Phase B.1 survey finding (commit 5725836 cancellable Whisper inference +
bounded drain + lock-over-await).
The upper comment on `start_live_transcription_session` claimed:
Released explicitly before the RunningLiveSession is installed in
`live_state.running` so the symmetric stop path doesn't observe a
half-initialised state.
The actual code (lines 801-811) does the opposite: it installs the
RunningLiveSession FIRST, then drops the lifecycle guard. That ordering
is the SAFER one — a concurrent stop_live acquiring lifecycle observes
a fully-installed `running` slot or none, never a half-initialised
state. The bug was in the comment, not the code.
Future-reader trap: an atomiser-grade review of the locking discipline
would have trusted the comment over the code and "fixed" the code to
match — reintroducing the half-initialised window between drop and
install. Rewrote the Phase 1 comment to describe the actual behaviour
(hold-through-install + Phase 2 drop) and explain why holding is
intentional. Phase 2 comment already accurate; left untouched.
Other B.1 findings:
* 8 existing unit tests cover the atomiser-targetable surface (Race-A
drop sets abort flag, drain_timeout helpers defend NaN/inf/negative/
zero/no-inflight cases, channel-loss observability, tracing target
convention).
* Race-B drain-timeout end-to-end test remains a known gap. The SAFETY
comment in drain_inference acknowledges it ("requires a wedged
whisper-rs, which is hard to fixture"). Closing it would require
refactoring LiveSessionRuntime for testability — invasive, no
identified residual bug in the audited 60-line drain_inference body.
* stop_live comments + code consistent. No fix needed.
Verification:
- cargo test -p lumotia --lib commands::live: 17/17 (no change to test
count — comment-only edit)
- cargo clippy -p lumotia --all-targets -- -D warnings: clean
- cargo fmt --check: clean
Critical bug surfaced by the dogfood drill: every upgrading Magnotia user
would silently keep a fresh empty Lumotia install while their Magnotia
data sat orphaned next to it. Drill caught it on the first real run
under sandboxed HOME.
ROOT CAUSE
src-tauri/src/lib.rs::run() previously called the migrations from inside
the Tauri setup hook (post `tauri::Builder::default()`). But three
sequential actions BEFORE the setup hook had already created the
destination directories:
1. init_tracing() -> logs_dir() -> create_dir_all(app_data_dir/logs)
creates the lumotia/ root.
2. install_panic_hook() -> crashes_dir() -> create_dir_all() ditto.
3. Tauri's WebKitGTK runtime / plugin chain creates the bundle-id-keyed
consulting.corbel.lumotia/ dir eagerly when the WebContext spins up
(mediakeys, storage, WebKitCache subdirs appeared even without our
hook explicitly creating them).
By the time the setup-hook migrations fired, every legacy candidate
returned `TargetAlreadyExists` (paths.rs) or `BothExistLegacyPreserved`
(tauri_app_data_migration.rs) — both silent no-op codepaths. Legacy
data was left untouched, fresh Lumotia install gained no transcripts,
settings, or window state.
FIX
Migrate BEFORE any other code touches app_data_dir().
src-tauri/src/tauri_app_data_migration.rs:
- NEW_BUNDLE_ID const ("consulting.corbel.lumotia"). MUST agree with
tauri.conf.json#identifier; reviewer-enforced invariant.
- Renamed private `legacy_tauri_app_data_dir_for` -> public
`tauri_app_data_dir_for(identifier)`. Function is parameterised by
bundle id; the "legacy" name was misleading after this change.
- New `current_tauri_app_data_dir()` resolves the NEW bundle path
from platform env vars (same convention Tauri 2 uses), so the
pre-runtime migration can address its destination without
needing an AppHandle.
src-tauri/src/lib.rs:
- New `migrate_user_data_pre_runtime()` orchestrates the two
migrations + ambiguity guard. Uses `eprintln!` for surface events
(tracing not yet initialised at this stage; stderr lands in
journald / foreground terminal which is the right transport for
boot-phase output). FATAL errors call process::exit(1) — the
setup-hook version returned Err from the closure, equivalent
effect.
- run() now calls migrate_user_data_pre_runtime() as its first line,
BEFORE init_tracing(), install_panic_hook(), and the Tauri
builder.
- Setup-hook migration blocks deleted (~90 lines). Setup hook now
starts with a one-line comment pointing at the pre-runtime fn.
VERIFICATION
Re-ran the dogfood drill (scripts/dogfood-rebrand-drill.sh) — 8/8 probes
pass after the fix (was 4/8). Both stderr lines fire:
[lumotia-startup] migrated legacy magnotia data dir to lumotia:
.../magnotia -> .../lumotia (renamed_db=true, elapsed_ms=0)
[lumotia-startup] migrated Tauri app_data_dir from legacy bundle
identifier: .../uk.co.corbel.magnotia ->
.../consulting.corbel.lumotia (elapsed_ms=0)
On-disk post-state confirms: magnotia/ gone, lumotia/ has migrated db
+ recordings, uk.co.corbel.magnotia/ preserved as backup,
consulting.corbel.lumotia/localStorage/leveldb/ has migrated data.
- cargo fmt --check: clean
- cargo clippy --workspace --all-targets -- -D warnings: clean
- cargo test --workspace: 409/0 (no regression)
scripts/dogfood-rebrand-drill.sh — end-to-end probe that launches the real
lumotia binary against synthetic legacy magnotia state on disk, then
verifies both migration paths produced the expected outcome:
1. paths.rs: ~/.local/share/magnotia/ -> ~/.local/share/lumotia/, including
magnotia.db -> lumotia.db rename + non-DB companion files carried along
by the directory rename.
2. tauri_app_data_migration.rs: ~/.local/share/uk.co.corbel.magnotia/
copied via atomic staging to ~/.local/share/consulting.corbel.lumotia/,
with legacy preserved as a backup and staging dir cleaned up.
Closes the last gap in Phase A: every other test (paths::tests + storage
integration test + localStorageMigration.test.ts) uses synthetic in-process
state. The drill is the only verification that the real binary's startup
hook calls migrate_legacy_data_dir + migrate_tauri_app_data_dir_with_paths
against real OS path resolution.
Two modes:
(default) Sandbox: HOME=<tempdir>, faithful on Linux. NOT
faithful on macOS — Tauri 2 uses
NSSearchPathForDirectoriesInDomains which ignores
HOME overrides. Drill refuses to start in sandbox
mode on macOS rather than silently writing to the
user's real Application Support tree.
--against-real-home Real $HOME. Refuses to start if any lumotia data
already exists at the real paths (no clobbering
real user data). Cleans up planted state on exit
unless --keep is passed.
Eight probes covering: data-dir rename outcome, db file rename, legacy
removal, companion file survival, Tauri app_data_dir copy, legacy-backup
preservation, staging-dir cleanup, and lumotia_startup log line presence.
README: documents the drill alongside cargo test + npm test in the
Testing section, with the macOS caveat clearly flagged.
Not run as part of this commit — the drill launches a Tauri WebView
window for a few seconds. Jake to invoke when ready to dogfood.
First frontend unit test framework on Lumotia. Pinned exact versions for
supply-chain hygiene (matches the rust-toolchain.toml discipline from the
27661c8 hygiene pass and the npm audit signatures pre-flight from e4d56b8):
- vitest 4.1.6 (compatible with vite 6, supports vite 6/7/8)
- jsdom 29.1.1
Installed with `npm install --save-dev --save-exact --ignore-scripts` per
the install discipline documented in the README — the --ignore-scripts
flag blocks the postinstall vector that npm worms (Shai-Hulud,
mini-Shai-Hulud) rely on.
vite.config.js:
- Switched defineConfig import to vitest/config (superset of vite/config;
production builds ignore the `test` key).
- test.environment = "jsdom" so storage-shim tests drive real browser APIs.
- test.include scoped to src/**/*.{test,spec}.{ts,js} — colocated with
source, mirrors the Rust #[cfg(test)] sibling pattern.
- test.exclude blocks src-tauri/ (owned by cargo test).
- restoreMocks + clearMocks + unstubAllGlobals on so module-level state
can't leak between tests.
src/lib/utils/localStorageMigration.test.ts — 12 tests:
migrateLocalStorageKey:
- copies value + removes old when only old exists
- removes old + keeps new when both exist (lumotia is authoritative)
- no-op when only new exists
- no-op when neither exists
- idempotent (second call after first migrates nothing)
- preserves the value's exact bytes (no JSON round-trip)
- preserves empty-string values (distinct from null)
- survives DOMException / quota errors without re-raising
- no-op when localStorage is undefined (SSR-safe)
migrateLocalStorageKeys:
- processes pairs in order
- per-pair failure does not strand remaining pairs (resilience)
- empty pairs list is a clean no-op
package.json:
- "test": "vitest run" (one-shot, CI-friendly)
- "test:watch": "vitest" (dev loop)
README: documents `npm run test` alongside `cargo test --workspace` and
`npm run check` in the Testing section.
Verification:
- npm run test: 12/12 pass
- npm run check: 0 errors, 0 warnings (the new .ts test type-checks clean
against jsconfig.json's strict typescript settings)
Phase A.3 finding: AppPaths::migration_sentinel was added with intent
during the rebrand-architecture phase but never wired to any caller.
Exhaustive grep across crates/ src-tauri/ src/ docs/ surfaces:
- 1 definition (paths.rs)
- 1 architecture-map description that ASSERTS the method is in use
- 0 production callers
- 0 test references
Both boot-time migrations (migrate_legacy_data_dir +
migrate_tauri_app_data_dir_with_paths) are idempotent by construction:
each re-probes the legacy path via Path::exists() on every boot and
short-circuits on the steady state. A sentinel file would optimise the
probe but is not required for correctness; one syscall per legacy
candidate at startup is negligible.
Per the atomiser principle of removing dead surface area rather than
keeping stale promises:
- Delete AppPaths::migration_sentinel entirely
- Update docs/architecture-map/.../core-paths.md to describe the actual
idempotency model (re-probing) rather than the sentinel pattern that
was never implemented
- Steer future migrations toward storage/src/migrations.rs schema_version
(transactional, survives backup/restore) rather than reintroducing
filesystem sentinels
Verification:
- cargo test -p lumotia-core paths::: 17/17 (no test relied on the method)
- cargo clippy -p lumotia-core --all-targets -- -D warnings: clean
Phase A.4 (stray-magnotia string scan): clean. Every magnotia reference
in the tree is legitimate — migration source paths, documentation, or
test fixtures. The rebrand cascade was thorough.
Phase A of dogfood verification for the Magnotia -> Lumotia rebrand
cascade. The existing in-crate unit tests prove the migration copies
bytes correctly; this commit closes the gaps an atomiser-grade review
would flag.
Phase A.1 — end-to-end integration test (crates/storage/tests/legacy_db_migration.rs):
Seeds a real on-disk magnotia.db via lumotia_storage::init (which runs
every schema migration head-to-tail), inserts a transcript via the
public API, drops the pool, runs migrate_legacy_data_dir_with_pairs,
then re-opens the migrated lumotia.db and asserts the transcript is
queryable. Three scenarios covered:
1. Legacy-only -> migrate -> reopen -> row survives. Also verifies a
non-DB companion file is carried along by the directory rename.
2. Idempotency: first boot migrates, user writes new data, second
boot is a no-op and BOTH rows survive.
3. Both-paths-present: refuses to merge, target's empty DB is
preserved, legacy retained on disk as a backup.
Wires the test surface by renaming the previously-private
migrate_legacy_data_dir_inner to pub migrate_legacy_data_dir_with_pairs
(mirroring migrate_tauri_app_data_dir_with_paths in the sibling
tauri_app_data_migration module).
Phase A.2a — copy_dir_recursive hardening (crates/core/src/paths.rs):
Pre-existing footgun: the fall-through branch called std::fs::copy()
on any DirEntry that was not a symlink or a directory. On Unix that
includes FIFOs, sockets, and char/block device nodes. Opening a FIFO
for read with no writer attached blocks forever — a stale debug FIFO
in the user's ~/.magnotia tree would silently hang first launch.
The branch now explicitly distinguishes is_file() (real regular file
-> copy) from anything else (-> Err with ErrorKind::Unsupported,
naming the offending path). Migration becomes re-runnable once the
user cleans up the offending node. Same-filesystem rename via
std::fs::rename is atomic and unaffected; only the EXDEV fallback path
touches the new guard.
Phase A.2b — three adversarial probes (crates/core/src/paths.rs tests):
- FIFO inside the legacy tree: copy_dir_recursive must return an
Unsupported error WITHOUT hanging. Test bounded by a 5s wall clock
+ a worker thread so a regression to the old fall-through would
surface as a panic, not a stalled CI job.
- Unreadable file (mode 0000): copy_dir_recursive must surface
PermissionDenied, not silently skip. Skips its core assertion under
euid 0 (root bypasses DAC permissions, would mask the regression).
- Dangling symlink (target nonexistent): symlink is recreated at
destination with link target preserved verbatim; the migration
does NOT try to dereference and does NOT abort the rest of the copy.
Verification:
- cargo fmt --check: clean
- cargo clippy --workspace --all-targets -- -D warnings: clean
- cargo test --workspace: 409 passed, 0 failed (up from 405 pre-commit;
3 storage integration tests + 3 paths adversarial + 1 net carry-over)
rust-toolchain.toml pins to stable 1.94.1 so contributors and CI runners
share the exact rustc / rustfmt / clippy versions. Without the pin, every
machine surfaces a different lint set depending on its local install — six
pre-existing lints showed up on 1.94.1 that 1.93-era HANDOVER reported clean.
Clippy fixes (all pre-existing, not introduced by feature work):
- crates/storage/src/database.rs: std::iter::repeat().take() -> repeat_n()
- crates/llm/src/lib.rs (docs): "+ frontends" was parsed as a markdown bullet
continuation by rustdoc, breaking doc-lazy-continuation. Reworded to "and".
- crates/llm/src/lib.rs (loop): while-let-on-iterator -> for-loop.
- src-tauri/src/commands/security.rs: .iter().any(|a| *a == x) -> .contains(&x).
- src-tauri/src/lib.rs: io::Error::new(Other, e) -> io::Error::other(e).
- src-tauri/src/tauri_app_data_migration.rs: drop function-tail `return`s
inside cfg blocks; each platform's block now ends with a tail expression.
cargo fmt sweep across the workspace. Mechanical layout-only changes;
no semantics affected.
Workspace gates after this commit:
- cargo fmt --check: clean
- cargo clippy --workspace --all-targets -- -D warnings: clean
- cargo test --workspace: 405/0 (will become 409/0 with Phase A.1+A.2)
Adds defence-in-depth against npm-worm attacks (Shai-Hulud / mini-Shai-Hulud).
- run.sh: gates dev launch on `npm audit signatures` whenever package-lock.json
is newer than .lumotia-last-audit. Fails loud on signature mismatch. Skip
with LUMOTIA_SKIP_AUDIT=1 for offline dev.
- README: documents `npm ci --ignore-scripts` as the install discipline
(blocks the postinstall vector worms exploit) and explains the audit hook.
- .gitignore: excludes the per-clone audit stamp.
Lumotia's current tree (192 packages) cross-references clean against the
mini-Shai-Hulud affected-package list — this is preventive, not remedial.
Before this commit `grep -rIn '#\[instrument\|.instrument(\|in_current_span()'`
returned zero matches across the entire workspace. Every tokio::spawn
and thread::spawn lost its parent span, so structured fields recorded
at the call site (session_id, chunk_id, model_id) did not propagate to
log lines emitted inside the spawn. During concurrent-session incidents
the operator could not correlate a runaway log line back to the request
that started it.
Targeted four highest-value join points:
* src-tauri/src/commands/live.rs::run_live_session
#[tracing::instrument(skip_all, fields(session_id, engine, language))]
Attaches the span to the spawn_blocking worker so every per-chunk
warning carries the session id that owns it.
* src-tauri/src/commands/live.rs::maybe_dispatch_chunk
Manual span attach pattern (#[instrument] can't decorate a closure):
capture the parent span before thread::spawn, .enter() it on the new
OS thread, then open an "inference" child span with chunk_id +
duration_secs. Without this, whisper backend warnings appear
unparented and a runaway chunk can't be traced back to its session.
* src-tauri/src/commands/models.rs::ensure_model_loaded
#[instrument(skip_all, fields(model_id, engine, concurrent))]
Multi-second load + sequential-GPU guard logs now carry the model
in flight as a structured field.
* crates/llm/src/lib.rs::load_model
#[instrument(skip_all, fields(model_id, use_gpu))]
Same rationale for LLM loads. Tags llama-backend init lines and
GPU sequential-guard events with the model identifier.
Storage/audio/hotkey/MCP crates left uninstrumented in this commit —
future sweep. The four sites above are the canonical concurrent-load
correlation points; everything else fans out from them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The drain-timeout warning in LiveSessionRuntime emitted with
`target: "lumotia_live"`, which EnvFilter treats as the literal
target string and not as a substring of `lumotia_lib::commands::live`.
The operator's documented triage filter
(`RUST_LOG=info,lumotia=debug,lumotia_lib::commands::live=debug`,
per docs/superpowers/audits/2026-05-10-phase10a-dogfood-notes.md)
therefore silenced the only warning that surfaces a wedged inference
worker. Drop the explicit `target:` so the emit picks up its
module-path target and falls under the existing filter directive.
`lumotia_startup`, `lumotia_storage`, `lumotia_hotkey`, etc. remain
deliberately custom targets — each is a separate semantic phase
with its own dedicated EnvFilter directive.
Regression test asserts no `target: "lumotia_live"` literal remains
in live.rs by scanning the file's own source. Skips comment lines
so the rationale prose does not self-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Trust-3/Trust-6 fix in commit 7aee534 added ensure_main_window to
copy_to_clipboard, paste_text, and paste_text_replacing. That was over-
broad: the transcript-viewer and transcription-preview windows
legitimately call copy_to_clipboard (their "copy raw" / "copy
transcript" buttons), and the transcription-preview window legitimately
calls paste_text_replacing (the preview paste-to-foreground flow).
The original Trust-3/Trust-6 finding was about asymmetric exposure to
arbitrary windows, not about banning the documented secondary windows.
Fix: add ensure_window_in_set helper in commands::security, mirror the
allow-list against src-tauri/capabilities/secondary-windows.json so the
IPC trust boundary and the permission grant stay in lock-step.
copy_to_clipboard -> main + transcript-viewer + transcription-preview
paste_text_replacing -> main + transcription-preview
paste_text -> main only (unchanged; no secondary caller)
Adds two unit tests for ensure_window_in_set_label covering the
listed-label accept path and the unlisted-label reject path. The
existing Trust-3/Trust-6 tests remain in place and continue to assert
the size cap and the constants-equality invariant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`transcribe_file` already had `ensure_main_window`, but accepted an
arbitrary `path: String` and fed it straight to
`lumotia_audio::decode_audio_file_limited`. The OS file picker
typically constrains the user's path, but the IPC surface itself never
checked: a compromised webview could point the decoder at a 50 GiB
sparse file (OOM the worker), or a deliberately-malformed blob with an
extension chosen to provoke a parser bug in Symphonia.
This change adds defence-in-depth:
- extension allowlist (`wav`, `mp3`, `m4a`, `mp4`, `flac`, `ogg`,
`opus`, `webm`, `aac`) matched case-insensitively. Anything else,
including no extension at all, is rejected with a clear error;
- 1 GiB ceiling on the input file. Stats via `std::fs::metadata`
(which resolves symlinks) so the cap sees the real blob, not a
symlink-target lie. The 2-hour duration cap still runs after decode
for the realistic-audio case.
The validation lives in a pure helper, `validate_transcribe_input`, so
the rule can be unit-tested without spawning Tauri or hitting the
decoder.
Eight unit tests cover: accepts plain `.wav`, accepts uppercase `.MP3`,
accepts every allowlisted extension, rejects `.so` payload, rejects
missing extension, rejects oversize file, accepts exactly-at-cap file,
rejects path-traversal with disallowed extension.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Live | Trash toggle to the HistoryPage header. The Trash tab
lists soft-deleted transcripts via the new list_trashed_transcripts
Tauri command and offers per-row Restore via restore_transcript.
Permanently-delete-from-trash is intentionally deferred — the 30-day
startup purge (TRANSCRIPT_TRASH_RETENTION_DAYS in src-tauri/src/lib.rs)
handles hard-removal until that UI lands. The retention policy is
surfaced in the panel header: Trashed items are kept for 30 days,
then permanently deleted on next app start.
Design notes:
- View toggle is a tablist of two pill buttons (Live | Trash); the
aria-selected attribute reflects the active mode.
- Switching to Trash triggers an effect that loads the list once.
Switching back to Live discards the trash data so stale rows
don't reappear on toggle.
- Live-mode header controls (Starred, Tag all untagged, Clear All)
are hidden in Trash mode and replaced with a Refresh button.
- Restore drops the row from the in-memory trash list rather than
splicing into history; the live store reloads from SQLite on its
own initialisation path, so we avoid drifting the in-memory shape
from the canonical source.
- The created timestamp is shown rather than the deletion
timestamp; deleted_at is not currently in the TranscriptDto and
expanding the DTO is out of scope for this fix.
- Audio files may already have been removed by delete_transcript's
best-effort filesystem cleanup, so restored text + metadata may
surface without playable audio (documented in the storage-layer
restore_transcript contract).
TODO(test): no Svelte component test framework wired in the repo
(vitest not installed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`extract_content_tags_cmd` was the only LLM command in `commands/llm.rs`
that did not call `ensure_main_window`. Every sibling (`load_llm_model`,
`unload_llm_model`, `delete_llm_model`, `test_llm_model`,
`cleanup_transcript_text_cmd`, `download_llm_model`) gates on it.
Without the guard a secondary-window webview could trigger a multi-
second llama.cpp inference run, blocking the LLM engine for the main
window and leaking model-inferred tags out of the History page's trust
boundary.
This change:
- adds a `window: tauri::WebviewWindow` parameter (Tauri injects it
automatically — `HistoryPage.svelte`'s `invoke("extract_content_tags_cmd",
…)` call site is unchanged and `npm run check` is clean);
- calls `ensure_main_window(&window)?` before the engine check so the
rejection is fast and the cap mirrors the rest of the surface.
Behaviour is otherwise identical: same engine path, same spawn_blocking,
same App-Nap power assertion.
The shared `ensure_main_window_label` test in `commands::security`
already covers the secondary-window rejection behaviour; no
command-level scaffolding for handler-style tests exists in this
codebase, so introducing one for a single new line was out of scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prior clearAll UX was a 4-second inline arm-confirm: one click on
Clear All morphed into Confirm/Cancel pills; a second click within
the window wiped every transcript. With the soft-delete backend
(commit 15b74db) now under it, the data-loss class is partially
closed — items land in Trash, not /dev/null — but an absent-minded
double-tap still soft-deletes the entire history at once.
Fix: replace the inline arm-confirm with a modal that requires the
user to type the word DELETE (case-sensitive, exact) before the
Confirm button activates. The single-transcript and bulk-selection
delete flows keep their lighter arm-confirm pattern; only the
all-at-once nuke is gated by the modal.
Modal details:
- autofocuses the input on open
- Enter submits when the word matches
- Escape closes; click-outside closes (when not in flight)
- Confirm button disabled until input == DELETE
- Clearing... spinner state while the SQLite soft-delete loop runs
- retention policy (kept for 30 days) surfaced in the body copy
- dialog role, aria-labelledby, aria-describedby, labelled input,
tabindex=-1 on the card; backdrop carries role=presentation to
keep the click-outside-to-close affordance out of the AT tree
clearAllArmed / armClearAll / disarmClearAll and their announcement
fragment are removed; bulkDeleteArmed (selection-subset) keeps the
arm-confirm pattern unchanged.
TODO(test): no Svelte component test framework wired in the repo
(vitest not installed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`paste_text`, `paste_text_replacing`, and `copy_to_clipboard` previously
exposed asymmetric trust against the rest of the Tauri command surface:
no `ensure_main_window` guard and no payload-size cap. A compromised
webview could synthesise an arbitrary Ctrl+V into the foreground
application or write multi-megabyte payloads into the system clipboard
without restriction. `paste_text*` is particularly hot because it also
synthesises keystrokes into whatever app currently has focus.
This change:
- adds `ensure_main_window(&window)?` to all three commands. Each now
takes a `tauri::WebviewWindow` parameter that Tauri injects
automatically — frontend invoke call sites are unchanged in their
TypeScript signatures and `npm run check` is green;
- introduces a shared 1 MiB cap (`MAX_CLIPBOARD_BYTES` /
`MAX_PASTE_BYTES`) that both surfaces enforce identically. Drift
between the two caps would let an attacker copy a >1 MiB payload via
one command and paste it via the other; a unit test asserts the
constants stay in lock-step.
Tests added:
- `commands::clipboard::tests` — accepts normal payload, accepts
exactly-at-cap, rejects above-cap.
- `commands::paste::tests_paste_size_cap` — accepts typical dictation
payload, rejects above-cap, asserts paste cap matches clipboard cap.
Note: `copy_to_clipboard` is currently invoked from the preview
(`/preview`) and viewer (`/viewer`) routes (HistoryPage and DictationPage
too, but those run in the main window). After this change the preview
and viewer invocations will surface a "main window only" error at
runtime. `npm run check` cannot catch this — flagged for follow-up; the
fix is to refactor those routes to delegate the copy through the main
window via an event.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier Trust-1 commits a2b47db and a48653c carried the wrong
files due to parallel-agent races on the index. This commit re-applies
the fs.rs change via explicit pathspec so the working-tree edit is
finally landed in HEAD.
The Tauri command `write_text_file_cmd` previously took an arbitrary
`path: String` and flowed it straight into `tokio::fs::write` with no
main-window guard, no canonicalisation, and no scope check. A
compromised webview could write anywhere the process had write access
— overwriting shell init files, dropping a runner into
`~/.config/autostart`, etc.
This change:
- adds `ensure_main_window(&window)?` so only the main webview can
invoke the command;
- canonicalises the requested path's parent (rejecting nonexistent
parents and resolving symlinks) before joining the filename;
- asserts the canonical target sits inside an allowlisted base
(app data, app local data, downloads, documents, desktop), so a
`"../../etc/passwd"` payload — even one obtained via symlink trickery
in the chosen save dir — is refused with a clear error.
Six unit tests cover: outside-allowlist rejection, path-traversal
rejection, nested inside-allowlist acceptance, plain inside-allowlist
acceptance, nonexistent-parent rejection, and the pure
`is_inside_any_base` prefix check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Corrective re-apply of Trust-1. The original commit a2b47db carried
the wrong staged file due to a parallel-agent race that swapped
the staged path between `git add` and `git commit` — fs.rs was
never actually changed by a2b47db despite the message. This commit
applies the Trust-1 fix properly.
The Tauri command `write_text_file_cmd` previously took an arbitrary
`path: String` and flowed it straight into `tokio::fs::write` with no
main-window guard, no canonicalisation, and no scope check. A
compromised webview could write anywhere the process had write access
— overwriting shell init files, dropping a runner into
`~/.config/autostart`, etc.
This change:
- adds `ensure_main_window(&window)?` so only the main webview can
invoke the command;
- canonicalises the requested path's parent (rejecting nonexistent
parents and resolving symlinks) before joining the filename;
- asserts the canonical target sits inside an allowlisted base
(app data, app local data, downloads, documents, desktop), so a
`"../../etc/passwd"` payload — even one obtained via symlink trickery
in the chosen save dir — is refused with a clear error.
Six unit tests cover: outside-allowlist rejection, path-traversal
rejection, nested inside-allowlist acceptance, plain inside-allowlist
acceptance, nonexistent-parent rejection, and the pure
`is_inside_any_base` prefix check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two `#[tauri::command]` wrappers around the soft-delete pair that
landed with migration v16 in commit 15b74db:
- `list_trashed_transcripts(limit, offset)` mirrors the existing
`list_transcripts` shape (default 50, clamp 1..=500, offset >= 0)
so the Trash view can paginate the `deleted_at IS NOT NULL`
partition with the same UX as the live history list.
- `restore_transcript(id)` clears `deleted_at`. Idempotent on a live
row, per the storage-layer contract. Audio at `audio_path` may
already have been removed by `delete_transcript`'s best-effort
filesystem cleanup; the text + metadata recovery is what
restoration actually buys you.
Both are registered in the `tauri::generate_handler!` block in
`src-tauri/src/lib.rs`, alongside the existing transcripts surface.
Mirrors the signature shape of `delete_transcript` — no
`ensure_main_window` because the rest of the transcripts command
surface does not gate on it; introducing that here would be
inconsistent and out of scope for this fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
resolve_recording_path() previously joined the webview-supplied
output_folder string verbatim into a PathBuf and then mkdir -p'd +
WAV-wrote at that path. The webview is a (mostly-)trusted surface in
Tauri, but the live-transcription command's input is JSON from the
frontend with no schema enforcement on the path field — so a
compromised page, a Tauri IPC sender on an OEM build, or a future
plugin reaching the same command can pipe through paths like `/etc`,
`/var/log`, or anywhere else the Lumotia process can write.
The new flow:
- None / empty → fall back to the default `app_local_data_dir/recordings`
base. Always safe.
- Non-empty → call validate_output_folder, which:
1. Ensures the default base exists (so canonicalise can succeed on
first launch).
2. Creates the requested path if it doesn't exist (so canonicalise
can resolve it; an empty directory outside the base is the only
side effect of an attempted escape, which is acceptable for the
trust gain).
3. Canonicalises both base and requested paths (resolves `..` and
symlinks).
4. Requires the canonical requested path to start_with the canonical
base. Reject otherwise with a message naming the trust boundary.
A user who legitimately wants recordings elsewhere routes through
Settings (a separately-validated persisted-preferences boundary). The
command surface stays constrained.
Regression tests (commands::audio::tests):
- validate_output_folder_accepts_base_itself
- validate_output_folder_accepts_descendant
- validate_output_folder_rejects_etc — `/etc` attack shape
- validate_output_folder_rejects_parent_escape — `..`-walk attack
- validate_output_folder_rejects_sibling_dir — prefix-overlap attack
(`recordings-backdoor` vs `recordings`). Canonical starts_with on
PathBufs correctly rejects this; a naive string-prefix check would
have let it through.
- validate_output_folder_rejects_symlink_pointing_out (Unix only) —
in-base symlink to outside path must be rejected after canonicalise
follows the link.
cargo test -p lumotia --lib commands::audio::tests: 8 passed.
cargo test --workspace: all green.
npm run check: 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
F3 derived the drain_inference timeout from the CHUNK_SAMPLES constant
and capped it near 6s. That breaks on the realistic worst-case
configuration (slow CPU + Whisper large-v3, 3-5x realtime): a 4-second
chunk legitimately takes ~20s to clear the decoder, but the F3 budget
aborted it after 6s and treated healthy work as a wedge — the lifecycle
keeps surviving, but every long-tail chunk gets cancelled and the user
sees their final stretch of dictation get dropped.
The deadline now derives from the in-flight task's own duration_secs
multiplied by a REALTIME_SAFETY_MULTIPLIER constant (5x — the
documented upper bound for the slowest supported backend), with a
DRAIN_TIMEOUT_FLOOR of 2s so sub-second tail chunks still get enough
wall-clock to amortise model load, OS scheduling jitter, and the
abort-callback's own poll cadence. Both constants sit next to
CHUNK_SAMPLES with doc comments explaining the rationale.
Defensive: non-finite or non-positive durations fall back to the floor
so a malformed task can't produce a NaN/overflow budget.
Regression tests (commands::live::tests):
- drain_timeout_scales_with_inflight_chunk_duration_secs: 4.0s chunk
must get 20s budget (5x), not the old 6s cap.
- drain_timeout_honours_floor_for_short_chunks: 0.3s chunk produces
1.5s scaled value, must be clamped to the 2s floor.
- drain_timeout_uses_floor_when_no_inflight_task: well-defined fallback
for the (in practice unreachable) None branch.
- drain_timeout_rejects_non_finite_duration: NaN / inf / 0 / negative
fall back to floor.
cargo test -p lumotia --lib commands::live::tests::drain: 4 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Tauri command `write_text_file_cmd` took an arbitrary `path: String`
and flowed it straight into `tokio::fs::write`, with no main-window
guard, no canonicalisation, and no scope check. A compromised webview
could write anywhere the process had write access — overwriting shell
init files, dropping a runner into `~/.config/autostart`, etc. The
in-file comment "the dialog already constrains the user's choice"
described an intended invariant the IPC surface never enforced.
This change:
- adds `ensure_main_window(&window)?` so only the main webview can
invoke the command;
- canonicalises the requested path's parent (rejecting nonexistent
parents and resolving symlinks) before joining the filename;
- asserts the canonical target sits inside an allowlisted base
(app data, app local data, downloads, documents, desktop), so a
`"../../etc/passwd"` payload — even one obtained by symlink trickery
in the chosen save dir — is refused with a clear error.
Six unit tests cover: outside-allowlist rejection, path-traversal
rejection, nested inside-allowlist acceptance, plain inside-allowlist
acceptance, nonexistent-parent rejection, and the pure
`is_inside_any_base` prefix check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Race-3 (conf 86): `LlmEngine::load_model` previously held the inner
`std::sync::Mutex` for the entire `LlamaBackend::init` +
`LlamaModel::load_from_file` call (5-15 s on a cold load). `is_loaded()`,
`loaded_model()`, and `loaded_model_id()` all take that same mutex and
are called from sync Tauri handlers (`get_llm_status`, `check_llm_model`,
`delete_llm_model`, `test_llm_model`) WITHOUT `spawn_blocking`. During a
first-run load, parallel `refreshLlmStatus()` polls from the frontend
parked tokio worker threads on the std-mutex; a handful of concurrent
status polls was enough to deadlock the default `num_cpus`-sized Tauri
runtime.
Lifecycle-1 (conf 80): same function held the OLD `Arc<LlamaModel>` in
`guard.model` during the new `load_from_file` call, so a model swap
peaked at ~2x VRAM. A 27B Q4 (~17 GB) swap OOMed a 24 GB card even
though either model fit alone.
Fix: redesign `load_model` so the slow llama-cpp work happens OUTSIDE
the mutex.
1. Short crit section: compare against currently-loaded triple —
return Ok on match (no-op fast path).
2. CAS a new `loading: AtomicBool` from false → true. If a load is
already in flight, refuse with the new `EngineError::AlreadyLoading`
rather than starting a parallel one. A `LoadingGuard` RAII drop
clears the flag on every exit path including panic.
3. Short crit section: drop the OLD model Arc (releasing VRAM via
`llama_free_model`) BEFORE the new load begins — fixes Lifecycle-1.
4. Heavy `LlamaBackend::init` + `LlamaModel::load_from_file` run
OUTSIDE the mutex.
5. Short crit section: install the new state.
`is_loaded()`, `loaded_model()`, `loaded_model_id()` now only contend
on the brief state-mutation sections, not the multi-second file load.
A new `is_loading()` accessor exposes the in-flight state for callers
that need to distinguish "loading" from "not loaded".
Backend lifecycle: `LlamaBackend::init` is process-singleton —
llama-cpp-2 enforces this via `LLAMA_BACKEND_INITIALIZED: AtomicBool`
and returns `BackendAlreadyInitialized` on a second call. The Drop impl
DOES call `llama_backend_free` and flips the flag back, so re-init
would technically work, but we keep the backend Arc resident across
loads/unloads to avoid init/free churn. The Lifecycle-1 fix drops only
the model Arc, NOT the backend Arc — dropping the backend mid-swap
would still work (the new load would re-init), but the comment trail
documents the singleton contract for the next reader.
`is_loaded()` now reports false briefly during a swap window (model
dropped, new model not yet installed). Documented in the doc comment.
Callers wanting "model X is loaded" must check `loaded_model_id()`
against their target, not just `is_loaded()`.
Regression tests added in `crates/llm/src/lib.rs::tests`:
- `is_loaded_does_not_block_on_slow_load`: holds the lock-discipline
open via a Barrier and asserts `is_loaded()` / `loaded_model_id()`
return in ≤50 ms while the slow section is mid-flight. Verified
that a pre-fix structure (`std::sync::Mutex` held across a 500 ms
sleep) makes the probe block ~450 ms; the new structure makes it
return in microseconds.
- `second_concurrent_load_is_refused`: two concurrent
`__test_run_with_lock_discipline` calls; the second gets
`EngineError::AlreadyLoading` and never reaches its op closure.
Doubles as the TOCTOU guard for Race-4/5 at the engine layer.
A `pub(crate) #[cfg(test)] __test_run_with_lock_discipline` helper
exposes the locking skeleton (loading flag + drop-old-model + slow op
outside lock + install) without requiring a real GGUF on disk; this is
the harness the regression tests use.
Verification: cargo test --workspace + npm run check both green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The trait's default implementation of transcribe_sync_with_abort fell
through to plain transcribe_sync, silently dropping the abort flag for
any backend that did not explicitly override it. That re-introduced the
5725836 wedge for SpeechModelAdapter (Parakeet today, any future
cloud STT or transformer adapter tomorrow): the live session's
drain_inference timeout would set the flag, the backend would ignore
it, the orphan inference thread would keep the engine Mutex held, and
the next start/stop would deadlock on it.
Removing the default impl makes the method required, so any backend
that does not implement it fails to compile. Compile-time enforcement
of cancellation completeness.
SpeechModelAdapter now implements the method explicitly with a
pre-decode short-circuit: if the abort flag is already set before we
dispatch into transcribe-rs (which owns the Parakeet decoder behind an
opaque call that has no cancellation hook), return an error
immediately. The in-flight decode itself is still uncancellable, but
that is documented with a SAFETY comment and bounded by the live
session's drain timeout dropping the receiver — the orphan exits the
instant it tries to send.
Regression tests:
- transcriber::tests::transcribe_sync_with_abort_is_required_and_flag_is_observed —
fake backend records the abort flag at dispatch time; proves the
trait method is required (file would not compile without it) and the
flag is actually piped through.
- local_engine::tests::speech_model_adapter_short_circuits_when_abort_set_pre_dispatch —
SpeechModelAdapter must NOT call the underlying decoder when the
abort flag was set before dispatch.
- local_engine::tests::speech_model_adapter_dispatches_decoder_when_abort_clear —
companion: clear flag must still dispatch (we did not kill
transcription entirely).
cargo test -p lumotia-transcription --lib: 64 passed, 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two interlocking reversibility kills, fixed as one bundle:
Rev-2 (hard-DELETE transcripts, no trash) — delete_transcript previously
issued DELETE FROM transcripts WHERE id = ?, so a single click on the
History "Clear All" / "Confirm" button erased months of dictation with no
trash, no export, no undo. The new contract:
* delete_transcript UPDATEs deleted_at = datetime('now') and best-effort
removes the audio file. Idempotent on repeat call.
* Migration v16 adds `deleted_at TEXT` plus a partial index over the trash
rows so the purge query stays cheap on long-running databases.
* get_transcript, list_transcripts_paged, count_transcripts, and
search_transcripts all filter `deleted_at IS NULL` so trash rows are
invisible to the regular history view but kept for restore.
* New list_trashed_transcripts and restore_transcript power the inverse
trash view; row order is most-recently-deleted first.
* New purge_deleted_transcripts(older_than_days) hard-removes trash
older than the retention window. Wired into the Tauri setup hook at
30-day retention; best-effort, never blocks startup.
Rev-3 (orphan WAV files on transcript delete) — same delete_transcript
function. Audio file at audio_path is now best-effort removed when the
soft-delete actually flips a row; NotFound is the expected case for
repeat deletes and is not logged. purge_deleted_transcripts also retries
audio removal as belt-and-braces.
Adds 4 regression tests: delete_transcript_soft_deletes,
delete_transcript_removes_audio_file, list_transcripts_excludes_soft_deleted
(also covers restore_transcript), and purge_deleted_transcripts_hard_deletes_old.
Plus migration_v16_adds_deleted_at_column_and_index already in place.
Frontend UI (HistoryPage clearAll type-the-word modal + View trash path)
deferred to a follow-up commit; the backend contract is complete and the
existing arm-confirm flow now soft-deletes instead of hard-deleting, so
the user-data-loss class is closed for the live release path. Rev-4
(SettingsPage deleteProfile routing) also deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes three interlocking concurrency leaks in the evdev hotkey listener
flagged by the atomiser full-sweep. Every spawned task is now owned by a
SupervisorHandle that broadcasts cooperative shutdown and joins every
JoinHandle with a 2s per-task timeout on stop(). Per-device attachment is
now insert-before-spawn under one mutex hold, closing the TOCTOU window.
The Tauri command layer now stores the forwarder JoinHandle alongside
the listener so reconfigures join it cleanly instead of leaking one
permanent forwarder per hotkey change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two reversibility kills in the model-download path both followed the
same pattern: SHA mismatch on an existing file triggered
`remove_file(&dest)` BEFORE the network round-trip. A network blip /
power loss between the unlink and the eventual `rename(.part, dest)`
left users with neither the old (corrupt-but-readable) model nor a
fresh one — 1.5-20 GB redownload from scratch with no fallback.
Rev-1 (crates/llm/src/model_manager.rs):
- Extract the existing-file decision into `download_to`, drop the
pre-emptive unlink. `download_impl` already writes via `.part`
and atomically renames; the rename overwrites on success and
leaves dest untouched on failure.
- Regression test `download_failure_preserves_existing_file` plants
a sentinel "OLD" file at dest, points at a 500-returning server,
and asserts dest still exists with original contents after the
failed download.
Rev-5 (crates/transcription/src/model_manager.rs):
- Drop the pre-emptive unlink in the outer `download()` SHA-mismatch
branch. Same atomic rename via `download_file`.
- Make `write_verified_manifest` atomic: write to `.tmp`, fsync,
rename. Previous direct `fs::write` truncates-then-writes, so a
crash mid-write left an empty/torn manifest and triggered a full
GB-sized redownload on next boot.
- `download_file_failure_preserves_existing_dest_file` and
`manifest_write_is_atomic` regression tests added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AUDIT NOTE: the diff for this fix was absorbed into commit afbd33d (Race-8)
by a concurrent agent's `git add`. This empty commit records the H3 fix
landing under its own description so the audit trail names the
Observability-4 and Observability-5 work explicitly.
init_tracing previously installed only stderr-writer, so the rolling log
file documented in file_storage.rs and bundled into diagnostic reports
by diagnostics.rs was never actually written. Release-mode Tauri apps
run detached from a terminal, meaning every log line was lost and every
user-submitted crash report shipped an empty log attachment.
Fix: add tracing_appender daily rolling file layer with 7-day retention.
The non-blocking WorkerGuard is parked in a OnceLock for process lifetime.
File-side filter is more verbose than stderr-side. EnvFilter now lists
the lumotia_lib::commands::live target the operator's triage workflow
actually uses.
Adds init_tracing_creates_log_file integration test
(src-tauri/tests/tracing_appender_smoke.rs).
Files touched (in afbd33d):
- src-tauri/Cargo.toml (added tracing-appender 0.2.5)
- src-tauri/src/lib.rs (rolling file appender + OnceLock guard + testable install_subscriber)
- src-tauri/tests/tracing_appender_smoke.rs (new smoke test)
- crates/storage/src/file_storage.rs (doc comment clarification only)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lockfile drift from the Magnotia → Lumotia rebrand: root and packages[""]
name fields still said "magnotia" while package.json correctly said
"lumotia". Regenerated via npm install --package-lock-only --no-audit
--no-fund. Diff scope: only the two name fields changed; no version
pins, resolved URLs, or integrity hashes shifted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an Option<bool> use_gpu parameter to test_llm_model with the same
default-true semantics as load_llm_model. Hard-coding true triggered an
engine tear-down/rebuild when a parallel load_llm_model(use_gpu=false)
was in flight (LlmEngine::load_model triples on id+path+use_gpu) and
silently flipped the user's GPU mode underneath the test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lock the invariant: the already-expired branch of rehydrate() must call
startTick() so the tick loop observes now >= completionFlashUntil and
auto-clears the flash. The call was already present; this commit adds
the inline comment so future edits cannot silently drop it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move both Tauri listen() calls inside the try block with let-declared
unlisten handles. A throw on the second listen() previously leaked the
first subscription and the finally block could itself throw on an
undefined unlistenParakeet, masking the original error. Finally now
guards each handle before invoking it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dropped_chunks was incremented on cpal-callback channel-full and
validation requeue overflow but never read by the live session, so
the UI's dropped_audio_ms missed callback-level losses entirely.
Architecture doc had flagged this as a TODO. Also: the 350ms
validation buffer was requeued via try_send into the same 32-slot
channel, silently dropping past the cap on small-buffer audio hosts
(WASAPI exclusive, low-latency ALSA at 256 frames -> ~65 chunks).
Fix: live runtime reads MicrophoneCapture::dropped_chunks() on each
recv_audio tick (LiveSessionRuntime::poll_capture_drops) and converts
the per-chunk-duration delta into the dropped_audio_ms surfaced to
the UI overload status. Per-chunk duration is derived from the most
recent AudioChunk's sample_rate + samples-per-channel so it adapts
to whatever rate cpal is delivering at. Validation requeue moved
from try_send into the bounded channel onto a VecDeque<AudioChunk>
returned alongside the Receiver; ActiveCapture drains the replay
buffer before reading rx in recv_audio, bypassing the 32-slot cap
entirely. Architecture doc updated to remove the TODO and document
the new pre-roll path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three findings chain: thread::spawn discarded the JoinHandle and gave
no cancellation route into the whisper backend; drain_inference busy-
polled with no timeout; stop_live_transcription_session held the
lifecycle AsyncMutex across the await of a non-cancellable
spawn_blocking handle. Together: a single wedged inference (ggml
deadlock, GPU stall) bricked every future start/stop until app restart.
Fix: each inference task carries an Arc<AtomicBool> abort_flag; the
flag is wired into whisper-rs::FullParams::set_abort_callback_safe so
the spawned blocking thread checks it and exits cleanly. drain_inference
is bounded by a deadline (3 x chunk_duration, min 2s); on expiry the
flag is set, the receiver is dropped, and a typed Error::InferenceTimeout
status is surfaced. Drop for InferenceTask asserts the abort flag so
any '?'-propagation or panic unwind closes the cancellation route
without relying on the explicit drain path. stop_live_transcription_session
restructured so the lifecycle guard is dropped BEFORE the JoinHandle
is awaited; start_live_transcription_session releases the guard
explicitly after installing the RunningLiveSession.
Unit test added: dropping_inference_task_sets_abort_flag covers the
Race-A regression directly. A real Race-B drain-timeout test would
require a wedged whisper-rs, which is hard to fixture; that path is
covered by the SAFETY comment and cargo build + manual smoke.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Commit 14313cf changed the Tauri bundle identifier from
uk.co.corbel.magnotia to consulting.corbel.lumotia. Tauri 2 keys both
app_data_dir and the webview's data store (localStorage, IndexedDB,
cookies, service worker, cache) plus all Tauri-plugin state files
(window-state geometry, autostart enable flag) by the identifier.
Without an explicit migration, every user's webview state was
silently orphaned on first launch under the new identifier; the
JS-side migrateLocalStorageKey helper introduced in 1608109 ran
against an empty store and no-op'd.
Fix: in the Tauri setup hook, before webview navigation, resolve the
legacy app_data_dir under the OLD bundle identifier and recursively
copy it to the new identifier's app_data_dir via an atomic staging
rename. Idempotent: legacy preserved as a backup; if both paths
exist post-rename, a warning is logged and the new path is used
unchanged.
Regression tests cover the migrate-only, both-exist, no-legacy, and
idempotent (run twice) cases against synthetic legacy/new paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two reversibility defects in `crates/core/src/paths.rs`:
Defect A (multi-legacy-candidate orphan):
`resolve_app_data_dir` and `legacy_and_target_paths` short-circuited
on the first legacy candidate, allowing two reachable orphan scenarios
on Linux. With both `~/.magnotia` and `~/.local/share/magnotia` the
shim migrated only the dot-home variant, leaving the XDG legacy
invisible forever. With a stray `~/.lumotia` alongside a freshly
migrated `~/.local/share/lumotia`, the resolver kept returning the
dot-home path, orphaning the XDG target.
`legacy_and_target_paths` now returns `Vec<(legacy, target)>`,
probing every legacy variant the platform supports. The migration
driver in `src-tauri/src/lib.rs` loops over the Vec and emits
per-candidate tracing. A new `resolve_app_data_dir_strict` +
`check_target_ambiguity` API refuses to start when more than one
target candidate exists post-migration, surfacing both paths to the
user via the setup hook instead of silently picking one.
Regression tests: `migrate_handles_both_dot_home_and_xdg`,
`resolve_app_data_dir_refuses_on_multiple_targets`.
Defect B (copy_dir_recursive symlink loop on EXDEV migration):
`entry.metadata()` follows symlinks, so a directory symlink reported
is_dir==true and recursed unconditionally. A self-referential or
ancestor-targeting directory symlink would loop until the disk
filled. Switched to `entry.file_type()` (symlink-aware), re-ordered
branches so `is_symlink()` is checked first, and routed all
symlinks through symlink-creation (Unix + Windows) rather than
recursive copy.
Regression tests:
`copy_dir_recursive_does_not_loop_on_self_referential_dir_symlink`,
`copy_dir_recursive_preserves_directory_symlinks`.
14/14 paths tests green. Full workspace cargo test green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Magnotia->Lumotia rebrand commit 26c7307 ran a too-greedy
s/magnotia/lumotia/g across comments that already had the correct
legacy/target distinction. The commit author caught one case in
src-tauri/src/lib.rs ("magnotia era" -> "lumotia era" restored) but
missed four others, plus a duplicated word in a Cargo description
from an earlier two-name sed.
Fixed sites:
* crates/storage/src/database.rs — migrate_legacy_setting_keys docstring
* src/lib/utils/localStorageMigration.ts — file-level docstring
* src/lib/utils/settingsMigrations.ts — historical-key comment
* crates/cloud-providers/Cargo.toml — description duplication
* src-tauri/src/lib.rs — data-dir migration log + doc
None of the underlying code paths were wrong; the lies were
docstring-only. Risk: future maintainer reading any of these
comments at incident time could invert the migration direction or
conclude no migration ran.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex independent review found 11 blockers post-cascade. All addressed.
CRITICAL (data-loss / crash):
10. crates/core/src/paths.rs — migrate_legacy_data_dir_inner used
fs::rename which fails with EXDEV when source + target are on
different filesystems (encrypted-home, bind mounts, separate
$XDG_DATA_HOME partition). Combined with the Phase 5 QC fix that
made migration errors fatal, this would crash on first launch
for any user whose data dir spans filesystems. Added
rename_or_copy_tree() that falls back to copy_dir_recursive +
remove_dir_all on CrossesDevices / errno 18 (EXDEV). Symlinks
preserved verbatim. Same fallback applied to magnotia.db ->
lumotia.db inside the dir.
11. Added 4 unit tests: copy_dir_recursive preserves nested
structure, rename_or_copy_tree same-filesystem happy path,
is_cross_device classifies CrossesDevices kind + raw errno 18.
Doc residuals (blockers 1-9):
1. crates/cloud-providers/Cargo.toml — "Wyrdnote pending rebrand"
description.
2. crates/cloud-providers/src/provider.rs — module docs + test
fixture Wyrdnote refs.
3. crates/transcription/src/orchestrator.rs — module docs + test
fixture Wyrdnote refs.
4. docs/roadmap/2026-05-10-pkm-phase-tooling-shortlist.md — phase
name + outputs/wyrdnote path refs.
5. docs/architecture-map/04-llm-formatting-mcp/llm-tests.md —
MAGNOTIA_LLM_TEST_MODEL env var.
6. .../cloud-providers-stubs.md — MAGNOTIA_API_KEY_*.
7. docs/architecture-map/03-audio-transcription/tests-and-fixtures.md
— MAGNOTIA_WHISPER_*.
8. docs/gpu-tuning/plan.md — MAGNOTIA_BENCH_RUN.
9. docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-
design.md + corresponding plan — MAGNOTIA_POWER_STATE_OVERRIDE,
MAGNOTIA_INFERENCE_THREADS.
cargo test --workspace: 343 pass / 0 fail (up from 339; +4 EXDEV
fallback tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 15.1 final-grep residuals:
- HANDOVER.md "Rebrand note" + Phase 10b row updated to reflect that
the cascade completed 2026/05/13 (15 phases, both repos, QC-gated).
- HANDOVER.md two surviving sed artefacts: "Lumotia -> Lumotia" line
restored to "Magnotia -> Lumotia" historical context;
MAGNOTIA_LLM_TEST_MODEL test gate -> LUMOTIA_LLM_TEST_MODEL.
- src/design-system/ui_kits/index.html: MagnotiaApp React function ->
LumotiaApp (sed boundary missed the no-separator boundary).
- docs/architecture-map/README.md: MAGNOTIA_LLM_TEST_MODEL doc note.
Preserved (audit trail):
- docs/handovers/ — historical handover docs.
- docs/superpowers/plans/2026-05-12-engine-slop-residuals.md and
-area-a-storage-errors-survey.md — describe the slop-pass work
using the names current at the time.
- build/index.html, package-lock.json — regenerate on next build/install.
cargo test --workspace: 339 pass / 0 fail. npm run check: 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6 of the rebrand cascade per locked decision D2.
src-tauri/tauri.conf.json:
- productName: "Magnotia" -> "Lumotia"
- identifier: "uk.co.corbel.magnotia" -> "consulting.corbel.lumotia"
- window title: "Magnotia" -> "Lumotia"
D2 picks the reverse-DNS of corbel.consulting (the actual domain CORBEL
trades under) over the prior uk.co.corbel.* convention. This is the
identity the OS uses for installed-app keying, so the first launch under
the new identifier will look fresh to Tauri's plugin state (window-state,
autostart). Per D1 the user-data dir is migrated by lumotia_core::paths
on first boot, so transcripts and settings survive.
cargo build --workspace passes. cargo test --workspace: 339 pass, 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 QC found two blockers + four advisories. All addressed:
B1 (FATAL) — Migration error now aborts startup instead of silently
continuing past it. Without this fix a transient EACCES / EXDEV / ENOSPC
would log a warning, init_db would create a fresh empty lumotia dir,
and the user would appear to lose their transcripts.
B2 (FATAL) — Linux dot-home vs XDG mismatch. The old probe returned
~/.magnotia as legacy but the caller passed app_data_dir() as the new
path — which could be $XDG_DATA_HOME/lumotia. fs::rename across
filesystems would EXDEV-fail; even when it succeeded the user's
storage convention silently changed.
Refactored: legacy_and_target_paths() returns the (legacy, target)
pair together. Dot-home legacy lands in ~/.lumotia; XDG-set legacy
lands in $XDG_DATA_HOME/lumotia; XDG-default legacy lands in
~/.local/share/lumotia. macOS / Windows / non-tier-1 unchanged.
migrate_legacy_data_dir() now takes no argument; src-tauri caller
updated.
A1 — Removed dead new_db.exists() check inside rename_db_file_if_present
(unreachable: rename_db is called only AFTER the legacy dir was just
renamed to the new path, so a stray lumotia.db there is impossible).
A2 — Added 4 unit tests for migrate_legacy_setting_keys: lone-magnotia,
both-present (orphan delete), no-magnotia, idempotent.
A3 — migrate_legacy_setting_keys now returns (renamed, orphans_deleted).
When both keys exist, the legacy magnotia row is DELETED in the same
transaction (lumotia row is authoritative).
A4 — Added dot-home convention regression test in paths::tests.
cargo build --workspace passes. cargo test --workspace: 339 pass, 0 fail
(up from 334 in the original Phase 5 commit; +5 new tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 of the rebrand cascade per locked decision D1 (migrate in place).
crates/core/src/paths.rs:
- Hardcoded subdir strings renamed: magnotia/Magnotia -> lumotia/Lumotia
across all four OS branches (Linux XDG + dot-legacy, macOS Application
Support, Windows LOCALAPPDATA, fallback dot-dir).
- Database filename: magnotia.db -> lumotia.db.
- Test path fixtures renamed: /tmp/magnotia-test -> /tmp/lumotia-test.
- New MigrationStatus enum (Migrated / TargetAlreadyExists / NoLegacyFound).
- New migrate_legacy_data_dir() that probes the platform-correct legacy
magnotia path, renames it to the lumotia equivalent via fs::rename, and
also renames magnotia.db -> lumotia.db inside if found. Idempotent: safe
to call on every boot. Refuses to overwrite an existing lumotia dir to
protect user data.
- Four new unit tests covering all branches via the test-friendly
migrate_legacy_data_dir_inner that takes an explicit legacy path.
crates/storage/src/database.rs:
- New migrate_legacy_setting_keys(pool) that renames any settings rows
with key matching magnotia_* to lumotia_*. Single SQL UPDATE with
NOT EXISTS guard so it leaves rows alone if a lumotia_ row already
exists. Re-exported from lib.rs.
src-tauri/src/lib.rs:
- Calls migrate_legacy_data_dir(&app_data_dir()) at the start of setup()
BEFORE database_path() resolves the now-renamed dir. Logs migration
outcome to lumotia_startup tracing target.
- Calls migrate_legacy_setting_keys(&db) immediately after init_db
returns. Logs only when rows are actually renamed.
src-tauri/src/{lib,commands/{diagnostics,rituals,transcripts}}.rs +
crates/storage/src/database.rs:
- All in-code references to magnotia_preferences, magnotia_history (in
comments), and magnotia_morning_triage_last_shown renamed to lumotia_*.
cargo build --workspace passes. cargo test --workspace: 334 pass, 0 fail
(up from 330; +4 paths::tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of the rebrand cascade. Updates the default RUST_LOG filter
in src-tauri/src/lib.rs and all 'target: "magnotia_startup"' string
literals across src-tauri/src/lib.rs and src-tauri/src/commands/models.rs.
Default filter (before):
warn,magnotia=info,lumotia_lib=info,lumotia_audio=info,
magnotia_startup=info,lumotia_hotkey=info,lumotia_ai_formatting=info
Default filter (after):
warn,lumotia=info,lumotia_lib=info,lumotia_core=info,
lumotia_audio=info,lumotia_hotkey=info,lumotia_ai_formatting=info,
lumotia_llm=info,lumotia_storage=info,lumotia_transcription=info,
lumotia_startup=info
magnotia_startup was a logical tracing target (not a crate); renamed
to lumotia_startup for consistency. magnotia_lib was already renamed
to lumotia_lib in Phase 2. Added per-crate filter entries for parity
across the workspace.
cargo build --workspace passes. cargo test --workspace: 330 pass, 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of the rebrand cascade per locked decision D4. MagnotiaError ->
Error in crates/core/src/error.rs (the crate name already qualifies it).
92 usages across 14 .rs files renamed via word-boundary sed.
One collision required disambiguation: lumotia_storage already had its
own local Error type (introduced by the slop-pass Area A residuals work).
crates/storage/src/error.rs aliases the imported core error as CoreError
on import; the From<Error> for CoreError boundary impl and the
CoreError::Storage construction site use the alias.
cargo build --workspace passes. cargo test --workspace: 330 pass, 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0 QC found two real blockers in the baseline commits:
1. crates/llm/src/lib.rs:215 — early-break guard was inverted. The
guard fired when grammar.is_some() (where grammar already enforces
shape, making the check redundant) and was disabled for
grammar.is_none() (the content-tags path that actually needs it).
Flipped to grammar.is_none() so the JSON-envelope short-circuit
fires for free-form JSON generation as the commit message
implied.
2. src/lib/pages/FilesPage.svelte — handleExport() success path had
no toast, asymmetric with DictationPage which does. Added the
toasts import and a toasts.success() call after the native save
so both export surfaces give consistent feedback.
cargo test -p magnotia-llm --lib: 21 pass. npm run check: 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DictationPage + FilesPage handleExport() now use Tauri save() +
write_text_file_cmd when in the desktop runtime; browser blob path
remains as fallback when hasTauriRuntime() is false. saveMarkdown.ts
surfaces dialog errors via toasts. Adds txt to the extension map.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
extract_content_tags now generates with grammar=None and parses the
response via a manual brace-counting JSON envelope extractor that
handles Qwen <think>...</think> prefixes and trailing stop tokens.
Five new unit tests. Bumps llama-cpp-2 to 0.1.146. Explicit
features=[] on tauri dependency (no-op).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Smoke checks (native window, Settings, Record, no error toasts) PASS on
the post-residuals-B/tracing/Area-A stack. Hermes session crashed
before the longer-form dogfood pass (transcribe + LLM cleanup + tagging)
could run; the LLM tagging crash from 2026/05/10 remains the
outstanding release-blocker. Tracing pipeline confirmed working under
the new default filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Commit 52565ea migrated storage to magnotia_storage::Error and flattened
typed storage failures into MagnotiaError::Storage { kind, operation,
detail }. No production code constructs the old
MagnotiaError::StorageError String variant anymore.
Remove the legacy variant so new storage failures cannot regress back to
stringly-typed errors.
Also updates living architecture-map docs that referenced the old
variant (core-error.md variant table, storage-overview.md
SQLITE_BUSY note, storage-crud-profiles.md duplicate-name + default-
profile-rename notes, storage-crud-transcripts.md pre-flight FK check
note) and one stale code comment in crates/storage/src/database.rs's
duplicate-name test. Survey doc + old residuals plan + phase8 historical
plan deliberately left alone — they're audit trail of how the migration
was decided, not living docs.
Pre-existing doc rot flagged but not fixed (Other(String) and
Io(std::io::Error) rows in core-error.md are about variants that
already don't match the actual enum shape — separate doc cleanup pass).
Verification:
- cargo fmt --all -- --check
- cargo check -p magnotia-core
- cargo check -p magnotia-storage
- cargo check --workspace --all-targets
- cargo test -p magnotia-storage — 60 passed, 0 failed
- cargo test --workspace --lib — all green
- rg 'StorageError\(' crates/ src-tauri/src/ — zero hits
- rg 'StorageError' crates/ src-tauri/src/ docs/architecture-map/ — zero
- rg 'Other\(String\)' crates/ src-tauri/src/ — zero
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Commit 1 of 2 for engine-slop residuals Area A.
Adds a typed `magnotia_storage::Error` enum and rewires every error
construction site in the storage crate to use it. The crate boundary
into `magnotia_core::error::MagnotiaError` is handled by a
`From<storage::Error> for MagnotiaError` impl that lives inside the
storage crate (not in core) to avoid a `core -> storage` dependency
cycle. The old `MagnotiaError::StorageError(String)` variant is kept
in this commit as an unused placeholder; commit 2 deletes it.
New types in crates/storage/src/error.rs:
- pub enum Error { DatabaseOpen, Migration, Query, NotFound,
InvalidReference, Filesystem } with #[derive(thiserror::Error)]
- pub enum OpenOp { Connect, ReadOnlyConnect, ForeignKeysPragma }
- pub enum MigrationStep { SchemaVersionTableCreate, SchemaVersionQuery,
TxBegin, Apply, RecordVersion, Commit }
- pub enum Entity { Transcript, Task, Profile, ImplementationRule,
Feedback } — seeded only with entities that have a real
NotFound/InvalidReference case in the codebase
- pub type Result<T> = std::result::Result<T, Error>
- impl Error { pub fn kind() -> StorageKind, pub fn
operation_label() -> Cow<'static, str> }
- impl From<Error> for MagnotiaError
New types in crates/core/src/error.rs:
- MagnotiaError::Storage { kind: StorageKind, operation: String,
detail: String } with #[error("{detail}")] so the boundary doesn't
double-prefix Display output
- pub enum StorageKind { DatabaseOpen, Migration, Query, NotFound,
InvalidReference, Filesystem } #[derive(Serialize)]
#[serde(rename_all = "snake_case")] for future Area E
Storage crate dependency added: thiserror = "1"
Migration scope:
- migrations.rs: 6 sites → Error::Migration with structured step + version
- database.rs: 72 sites broken down as:
* 3 → Error::DatabaseOpen (init / readonly / pragma)
* 1 → Error::Filesystem (init's create_dir_all with path context)
* ~58 → Error::Query with operation labels matching the prior message
stem in snake_case; transaction sub-steps use dotted labels
("complete_subtask_and_check_parent.commit_transaction" etc.)
so transaction internals are not collapsed
* 5 → Error::NotFound (transcript / task×2 / implementation_rule×2)
* 5 → Error::InvalidReference (insert_transcript unknown profile;
Default profile rename/delete invariants ×3; feedback rating
value validation)
Survey ambiguities resolved per the locked answers:
- Q1 (schema_version_query): Migration step, not Query
- Q2 (transaction sub-steps): preserved granularity with dotted operation
labels; no collapsing
- Q3 (Entity cardinality): seeded with the 3 from the survey + 2 more
discovered during migration (ImplementationRule, Feedback), per
"add when an actual case needs it"
- Q4 (operation label type): Cow<'static, str>
- Q5 (Serialize): storage::Error is not serializable; flattens only at
the MagnotiaError boundary
- Q6 (re-exports): pub use error::{Entity, Error, MigrationStep, OpenOp,
Result} in storage::lib.rs; StorageKind belongs to magnotia_core
Two scope-discovered additions beyond the original 5 variants:
- Error::Filesystem { path, source } variant + matching StorageKind —
required because init() now returns storage::Result<SqlitePool> and
the create_dir_all call needs a typed path; doing this via
From<io::Error> for storage::Error would have lost the path so it's
explicit
- Entity::ImplementationRule and Entity::Feedback — two NotFound /
InvalidReference sites the original survey missed in the rule-CRUD
and feedback-validation areas
Verification:
- cargo fmt --all
- cargo check --workspace --all-targets — clean
- cargo test -p magnotia-storage — 60 passed, 0 failed
- cargo test --workspace — all green, ~330 tests
- rg 'StorageError\(' crates/ src-tauri/src/ — only hit is the variant
declaration in core that commit 2 will delete
- rg 'Other\(String' crates/storage/src/ — zero
- rg 'format!\("[A-Z].* failed' crates/storage/src/ — zero
Commit 2 will delete MagnotiaError::StorageError(String) once this is
in. Pre-existing working-tree churn in crates/llm/, src/lib/pages/,
src/lib/utils/saveMarkdown.ts and the untracked phase10a dogfood notes
deliberately left unstaged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-migration survey for engine-slop residuals Area A. Catalogues every
MagnotiaError::StorageError construction site in the storage crate
(78 total across database.rs and migrations.rs), groups by failure
mode, proposes a typed magnotia_storage::Error enum with a serde-
friendly MagnotiaError::Storage { kind, operation, detail } variant on
top, and flags 6 ambiguities + 3 migration risk classes.
Key findings the residuals plan did not capture:
- StorageError is a String variant on MagnotiaError, not a separate
enum — there is nothing called StorageError in the storage crate.
- Actual site count is 78, not the ~25 the residuals plan estimated.
- Tauri commands stringify every error before crossing into the
frontend (Result<T, String>), so MagnotiaError's Serialize impl is
presently unused at the FE/BE boundary. Area E owns that fix.
- Zero Other(String) sites in storage — already cleaned in db654de.
No code changes. Migration awaits decisions on the 6 ambiguous cases
in §"Ambiguous cases / decisions needed".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 184214b. The eprintln→tracing sweep surfaced 6 existing
log::* calls in the hotkey crate that were silent at runtime — the
workspace builds tracing-subscriber without the tracing-log feature, so
log:: events never reached the tracing pipeline.
Migrating direct rather than adding a LogTracer bridge: the repo is
already standardising on tracing, the bridge adds a second logger init
path with "already initialised" edge cases, and the bridge would
indiscriminately route all log records (including future third-party
chatter), not just these known sites.
Migrated (6 sites, 2 files):
- crates/hotkey/src/linux.rs (5) — read /dev/input error, device open
debug, device-attached info, device-listener-ended warn, event-channel-
closed warn
- crates/hotkey/src/stub.rs (1) — non-Linux no-op info
Also removed the now-unused log = "0.4" dependency from
crates/hotkey/Cargo.toml. magnotia_hotkey=info filter target was
already added to init_tracing in 184214b, so these events emit at the
default level immediately.
Verification:
- cargo fmt --all -- --check
- cargo check -p magnotia-hotkey — clean
- cargo check -p magnotia — clean
- rg 'log::|eprintln!' crates/hotkey/src/ src-tauri/src/commands/ crates/ai-formatting/src/ — zero hits
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces 22 production eprintln! sites with structured tracing events
across 8 files. Closes Area B of the post-prognosis residuals plan
(docs/superpowers/plans/2026-05-12-engine-slop-residuals.md).
Files touched (22 sites):
- crates/hotkey/src/linux.rs (2) — hotplug watcher degraded-mode warnings
- crates/ai-formatting/src/pipeline.rs (1) — LLM cleanup fallback warning
- src-tauri/src/commands/transcription.rs (1) — chunking dispatch info
- src-tauri/src/commands/diagnostics.rs (1) — crashes-dir setup warning
- src-tauri/src/commands/tasks.rs (1) — malformed feedback row warning
- src-tauri/src/commands/power.rs (3) — App Nap acquire/release/fail
- src-tauri/src/commands/models.rs (5) — Whisper warmup lifecycle
- src-tauri/src/commands/live.rs (8) — session start, chunk dispatch,
per-chunk delivery, inference errors, worker disconnects, listener
loss, status-channel cascade
Levels: error for unrecoverable failures (inference disconnect, panic,
status cascade), warn for recoverable degradation (LLM fallback,
malformed rows, App Nap fail, hotplug watcher fail), info for lifecycle
(session start, chunk processed, App Nap acquire/release, warmup
complete, chunking dispatch), debug for per-chunk noise (speech-gate
skip, chunk dispatch).
Two new dependencies and two new filter targets:
- tracing = "0.1" added to crates/hotkey and crates/ai-formatting
- Default EnvFilter in src-tauri/src/lib.rs::init_tracing extended with
magnotia_hotkey=info,magnotia_ai_formatting=info so the new targets
emit at the default level
Out of scope (intentional, left as-is):
- crates/mcp/src/main.rs — CLI binary, stderr is the log contract
(module docstring) so the JSON-RPC stdout stream stays clean
- crates/*/tests/*.rs and crates/core/examples/tuning_log_demo.rs —
test/example diagnostic output relies on --nocapture stdio semantics
Discovery during sweep (not fixed — separate follow-up): hotkey crate
has 6 existing log:: calls (log::error/warn/info/debug) but the
workspace builds tracing-subscriber without the tracing-log feature, so
those events are currently silent. Worth a follow-up to either add the
tracing-log bridge or migrate hotkey's existing log:: calls to
tracing::.
Verification:
- cargo fmt --all
- cargo check --workspace --all-targets — clean
- cargo test --workspace — 330+ tests, zero failures
- rg eprintln! src-tauri/src/commands/ crates/hotkey/src/ crates/ai-formatting/src/ — zero hits
Pre-existing working-tree churn in crates/llm/, src/lib/pages/,
src/lib/utils/saveMarkdown.ts and the untracked phase10a dogfood notes
deliberately left unstaged per Jake's instruction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 2026-05-12 engine-slop pass removed runtime std::env::set_var mutation from src-tauri/src/lib.rs and replaced it with warn_if_x11_env_unset_on_wayland. With no launcher owning the contract, Linux Wayland dogfood was about to regress.
run.sh:
- now owns Linux launcher env defaults via case "$(uname -s)"
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 (user-set wins)
WEBKIT_DISABLE_DMABUF_RENDERER=1 (always on Linux; user-set wins)
GDK_BACKEND=x11, WINIT_UNIX_BACKEND=x11 (Wayland only; user-set wins)
- 60s Vite readiness timeout
- detects early Vite exit (kill -0 + wait) instead of hanging forever
- trap installed before wait so Ctrl-C cleans up
- args forwarded: ./run.sh --release etc.
- non-exec final Tauri launch preserved so cleanup trap fires
package.json:
- "dev:tauri": "./run.sh" — canonical discoverable dev command
Docs:
- README, dev-setup, architecture-map runtime + launcher pages updated with the new contract; canonical command is npm run dev:tauri (./run.sh as direct equivalent)
- dev-launcher-and-scripts.md replaces the incorrect "kills process group" claim with honest "kills the spawned npm process"
- dev-setup.md path /CORBEL-Projects/magnotia → /CORBEL-Projects/transcription-app
- gpu-tuning/plan.md gets a superseded note rather than rewriting the original rationale
- engine-slop-residuals.md gains Area F (packaged-binary launcher contract) with honest wrapper-vs-.desktop trade-off documented
Verification: bash -n run.sh; shellcheck clean; cargo check -p magnotia green; npm pkg get 'scripts.dev:tauri' returns "./run.sh"; stale-ref sweep clean across living docs.
Wyrdnote scales from voice-first dictation today to local-first
semantic PKM workbench tomorrow per the engine architecture spec.
The PKM phase needs a serving stack for embeddings + reranking +
entity extraction; this note bookmarks SIE (Superlinked Inference
Engine, Apache-2.0) as the trigger candidate plus six adjacents
to bake off against when the PKM phase lands.
Deferred because (1) PKM sits post-public-beta and infrastructure
decisions today would generalise poorly to a Q4 2026 selection;
(2) the candidates evolve fast; (3) Wyrdnote's local-first +
single-consumer-GPU constraint rules out a today-style bake-off
without real PKM-shape workload to test against.
Decision dimensions captured for the future evaluator: self-host
footprint, cold-start latency, throughput, quality on a Wyrdnote
eval set, reranker availability, extract/NER capability,
OEM-licensability against AGPL-3.0+dual-licence stack, telemetry
posture. Re-evaluation triggers documented.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the architectural spine for the Wyrdnote engine layer per
the spec at outputs/wyrdnote/2026-05-10-engine-architecture-spec.md
in the CORBEL-Main workspace, Codex-reviewed 2026/05/10.
Clean-room derivative of VoiceInk's TranscriptionService /
TranscriptionServiceRegistry / TranscriptionPipeline shape. Built from
the architectural pattern only; no GPL-3.0 code, comments, or
identifiers lifted. Wyrdnote names, control flow, and trait signatures
are original.
What this lands:
crates/cloud-providers/src/provider.rs (new, 184 lines)
TranscriptionProvider async trait. Object-safe via async_trait so
Arc<dyn TranscriptionProvider> is the canonical shape. Lives in
cloud-providers (not transcription) per D7 so an OEM licensee
implementing the trait depends only on this crate plus magnotia-core
(no transcription internals leaked through the trait surface).
Surrounding types: ProviderId (lower-kebab-case), ProviderKind,
NetworkRequirement, CostClass, ProviderCapabilities, EngineProfile,
ProviderTranscript. Compile-time object-safety witness in tests.
crates/cloud-providers/Cargo.toml (modified)
Adds async-trait + serde dependencies.
crates/cloud-providers/src/lib.rs (modified)
Re-exports the provider surface.
crates/transcription/src/registry.rs (new, 183 lines)
EngineRegistry: catalogue of providers keyed by ProviderId. Push-style
registration, default-provider id captured at construction. Read-only
after boot. Full unit-test coverage: empty default, register/get
round-trip, default-resolves-after-registration, re-register replaces,
ids enumeration.
crates/transcription/src/orchestrator.rs (new, 286 lines)
LocalProviderAdapter wraps Arc<LocalEngine>, presents async
TranscriptionProvider upward via tokio::task::spawn_blocking. Per D7
the adapter lives in the orchestrator, NOT as impl TranscriptionProvider
for LocalEngine — this keeps the dependency edge one-directional and
avoids leaking async-runtime requirements onto the synchronous
Transcriber trait.
Orchestrator: single transcribe() entry point; resolves a provider
from the registry, derives TranscriptionOptions from the EngineProfile,
dispatches. Returns a clear error when an unregistered provider is
named. Unit tests use a mock CannedProvider to validate dispatch,
error handling, and option routing without booting a real model.
crates/transcription/Cargo.toml (modified)
Depends on magnotia-cloud-providers + async-trait. Dev-dep tokio
gains macros + rt-multi-thread features for #[tokio::test].
crates/transcription/src/lib.rs (modified)
Exports Orchestrator, EngineRegistry, LocalProviderAdapter, and
re-exports the provider trait surface so downstream crates depend
only on magnotia-transcription for both local engines and the trait.
KNOWN-ISSUES.md (modified)
Adds KI-06: existing dictation, live, and meeting commands still call
LocalEngine::transcribe_sync directly via pick_engine. The orchestrator
path is dormant until a Phase A.1 follow-up commit migrates the call
sites. Cloud providers (Phase G) cannot be exercised end-to-end until
the rewire lands. Phasing rationale: this commit lands the abstractions
cleanly with full test coverage; the rewire is a separate diff so the
chunking + post-processing logic in transcribe_file moves without
inflating this commit beyond review fidelity.
Test results: 9 new tests pass (orchestrator: dispatches, errors-on-
unregistered, routes-initial-prompt, object-safe; registry: empty-default,
round-trip, default-resolves, re-register, ids-list). Pre-existing
59 transcription tests + 5 cloud-providers tests still green.
cargo check --workspace clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
settings.theme and preferences.theme overlap: the legacy localStorage
field still drives two SegmentedButton bindings in SettingsPage.svelte
while preferences.theme is the canonical Tauri-persisted store. A
migration \$effect in the four route +layout files syncs the legacy
value into preferences whenever it changes.
Resolution requires touching SettingsPage.svelte (2 484 LOC),
src/lib/stores/page.svelte.ts, four route layouts, and the localStorage
migration system. That is moderate-risk frontend work, not pre-launch
polish, so it lands in KNOWN-ISSUES with a concrete plan and is
deferred from v0.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sweep of registered-but-never-invoked commands surfaced by an audit
against the frontend invoke() call sites. Each was confirmed dead via
grep across src-tauri, src, and crates: no caller anywhere.
Removed commands:
- check_model, count_transcripts_command, get_profile_cmd,
install_update, list_feedback_examples_cmd (utility/CRUD shapes
never wired)
- save_audio, start_native_capture, stop_native_capture (native-capture
path superseded by the live transcription session)
- transcribe_pcm, transcribe_pcm_parakeet (PCM commands superseded by
live session; no frontend caller)
- close_preview_window (preview window is hidden via the
core:window:allow-hide capability, not the command)
Cascade in audio.rs (~430 lines removed):
- CaptureWorker, NativeCaptureState struct + impl, stop_worker,
append_recorded_chunk, MAX_NATIVE_CAPTURE_RETURN_SAMPLES,
persist_audio_samples
- The two cfg(test) tests that exercised stop_worker (the
recording_filename tests stay, supporting resolve_recording_path
which the live session uses)
Cascade elsewhere:
- FeedbackDto struct and its From<FeedbackRow> impl
- Stale storage imports in feedback.rs, profiles.rs, transcripts.rs
- tauri::Emitter import in transcription.rs
- app.manage(NativeCaptureState::new()) in lib.rs setup
generate_handler entries removed for all 11 commands. cargo check passes
cleanly with zero warnings; no tests reference any deleted symbols.
Net: 709 deletions, 17 insertions across 9 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
transcribe_pcm and transcribe_pcm_parakeet did not check that their
respective engines were loaded before clone+spawn_blocking. transcribe_file
already calls ensure_model_loaded; these now mirror that posture with a
friendly error when the engine is unloaded, matching what
extract_content_tags_cmd does.
decompose_and_store and extract_tasks_from_transcript_cmd ran multi-second
LLM inference inside spawn_blocking without the PowerAssertion guard that
cleanup_transcript_text_cmd and extract_content_tags_cmd already use. Both
now begin a guard so the macOS App-Nap inhibitor (and the planned
Linux/Windows equivalents per KI-02, KI-03) can pin the process for the
duration of the inference.
HANDOVER.md gets a status note clarifying it captures Phase 9 state. The
schema head referenced (v14) was the head at session time; current head
is v15 (`idx_transcripts_profile_created` composite index) per the
architecture map.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PowerAssertion file-level doc previously claimed Linux logind and
Windows SetThreadExecutionState implementations in present tense.
Both are no-ops; the macOS path compiles but is unverified on
Apple Silicon (RB-08). Rewrite top doc to state present vs
planned posture and reference KNOWN-ISSUES.md.
Surfaced as tracked limitations:
- KI-01: macOS App Nap guard pending Apple Silicon verification
- KI-02: Linux power assertion is a no-op
- KI-03: Windows power assertion is a no-op
- KI-04: magnotia-cloud-providers crate not user-exposed in v0.1
(in-memory keystore needs OS keychain before any save-key UX)
README links to KNOWN-ISSUES.md from Status, Platform support
table, and Project documentation. Platform support table notes
adjusted per OS to reflect actual idle-inhibit posture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five-slice navigable map of the entire codebase under
docs/architecture-map/. Each slice is a self-contained
breadcrumbed sub-tree:
01-frontend (16) Svelte/SvelteKit UI
02-tauri-runtime (26) src-tauri commands + lifecycle
03-audio-transcription (16) audio + transcription crates
04-llm-formatting-mcp (19) llm, ai-formatting, mcp, cloud
05-core-storage-hotkey-build core, storage, hotkey, workspace,
(26) CI, dev glue
Plus master README.md and data-flow-end-to-end.md tracing
audio bytes from microphone to FTS5 search to MCP read.
Generated by 5 parallel subagents on 2026/05/09 against
HEAD 3c47000. Each page has YAML frontmatter, file:line code
refs, sibling cross-links, plain-English summaries.
Aggregated debt surfaced (full lists in master README):
RB-08 macOS power assertion, schema head drift v14 vs v15,
VAD blocked on ort version conflict, streaming primitives
not wired into live.rs, no prompt versioning, MCP has no
auth, cloud-providers in-memory keystore, SettingsPage
2 484 LOC, commands/live.rs 1 737 LOC, dual theme system,
brand rename to Lumenote pending across the codebase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run with:
cargo run -p magnotia-core --example tuning_log_demo
Initialises a tracing-subscriber fmt layer so the inference_thread_count
INFO event is rendered to stderr. Exercises all 8 (workload,
gpu_offloaded) combos under three power scenarios: AC override,
battery override, and the real sysfs probe.
Used to validate the heuristic on 2026-05-09 against the spec's
6c12t truth table — all 6 rows match.
Adds tracing-subscriber as a [dev-dependencies] for the example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the final reviewer's suggestion: the gpu_offloaded cross-check
(gpu_layers >= model.n_layer()) is trivially true when use_gpu since
we always pass u32::MAX. The check documents intent and is future-
proofed if we ever pass specific N, but a future reader might
simplify it away as dead code. Inline comment points at the spec's
out-of-scope follow-up for true residency observability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-phase cleanup of items flagged across review passes for tasks
1.1, 1.2, and 1.3:
1. crates/core/src/lib.rs: pub mod power moved to alphabetical
position (between paths and process_watch).
2. crates/core/src/power.rs: use statements moved to top of file
(above the PowerState enum) per Rust convention.
3. crates/core/src/power.rs: parse_power_state_from_dir docstring
tightened to acknowledge that read_dir.flatten() silently skips
per-entry errors. Theoretical hazard only on world-readable
sysfs, but the previous wording overstated the guarantee.
No behavioural change. All 40 magnotia-core tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the JFK thread-count sweep to print four labelled panels
(AC+CPU, AC+GPU, battery+CPU, battery+GPU) driven by
MAGNOTIA_POWER_STATE_OVERRIDE. Each panel shows the helper's
predicted thread count for its (power, gpu) combination alongside
the empirical RTF table for n_threads = [1, 2, 4, physical, logical,
maybe 8].
The actual whisper context is initialised once (Vulkan if compiled
in and resolvable, CPU otherwise), so the RTF rows themselves are
produced by the same backend across panels. The CPU vs GPU axis
controls only the helper-pick column, which is the empirical
question we want answered: is the LLM=2 / Whisper=4 GPU floor right?
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS, and
inference_thread_count() defined in constants.rs were superseded by
the equivalents in tuning.rs (Task 2.1) and are no longer called by
any production code. Both call sites (whisper backend Task 4.1, LLM
generate Task 5.1) have migrated.
Constants.rs now holds only audio/mel/RAM/VAD/download constants;
inference-tuning concerns live in tuning.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the LLM call site through the new tuning helper. gpu_offloaded
reflects intent (use_gpu) cross-checked against the loaded model's
layer count: u32::MAX (when use_gpu) is trivially >= any model's
n_layer, but the explicit comparison is future-proofed if we ever
pass a specific N instead of u32::MAX.
Note: the call site is in generate() not load_model() as the plan
suggested. Context params (and thus thread count) are constructed
per-inference, not per model load, since n_ctx depends on prompt
size. The implementer adapted correctly.
The old magnotia_core::constants::inference_thread_count import is
replaced. Task 6.1 removes the constants helper now that both call
sites have migrated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the whisper backend through the new tuning helper. gpu_offloaded
combines a compile-time feature check (whisper-vulkan, in default
features) with a runtime libvulkan probe via the hardware module. If
libvulkan1 is missing on Linux, whisper-rs's vulkan backend silently
falls back to CPU, so we should not reduce threads in that case.
The old magnotia_core::constants::inference_thread_count import is
replaced by tuning::{inference_thread_count, Workload}. The constants
module's helper is removed in Task 6.1 once the LLM call site has
also migrated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Delete the local duplicate fn and libloading dependency from src-tauri;
import the canonical implementation from magnotia-core::hardware instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds libloading = "0.8" to magnotia-core dependencies and moves
vulkan_loader_available() into magnotia-core::hardware so non-Tauri
crates (transcription, llm) can probe the Vulkan loader without
depending on the Tauri binary. Test asserts the call completes on any
host regardless of whether libvulkan is installed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a OnceLock<Mutex<HashSet>> cache in tuning.rs. inference_thread_count
now emits a single tracing::info! line the first time each
(workload, on_battery, gpu_offloaded) tuple is seen in the process,
recording the resolved thread count and which clamps fired (battery, gpu).
Also derives Hash on Workload and tightens the GPU clamp to only record
the "gpu" clamp when it actually reduces chosen. Smoke test added.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds battery-state awareness to the thread-count helper: when
probe_power_state() returns OnBattery, chosen is halved before the
[MIN, MAX] clamp. Also removes the leading underscore from the
workload/gpu_offloaded parameters (they are silenced via let _ = ...
until the GPU-clamp task).
New test battery_halves_thread_count verifies on_battery <= on_ac and
>= MIN when the host has more than MIN physical cores. Restructured
to sequential with_override calls (not nested) to avoid re-entrant
deadlock on power::TEST_LOCK.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without a lock, env_var_bypasses_clamps (set_var + remove_var) and
matches_existing_clamp_when_no_clamps_apply (remove_var) race under
cargo's parallel test runner. Task 2.2 will add another remove_var
call, compounding the race.
Adds a #[cfg(test)] static THREAD_ENV_LOCK and a closure-style
with_thread_env_lock helper, mirroring power::with_override.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds crates/core/src/tuning.rs with MIN/MAX_INFERENCE_THREADS consts,
Workload enum (Llm/Whisper), and inference_thread_count() helper matching
the existing constants::inference_thread_count clamp behaviour. Three unit
tests pass. tracing dep added to Cargo.toml (used by future tasks).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cache sits between override resolution and platform_probe; override
paths (test + env-var) bypass it entirely so they always take effect
immediately. OnceLock<Mutex<Option<CachedState>>> initialised lazily on
first probe. Two test-only helpers (force_clear_cache, force_set_cache)
expose the cache slot for unit tests. Mutex import ungated — now used in
production cache code as well as test override.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TEST_OVERRIDE and test_get_override are now #[cfg(test)]-gated and
the read in probe_power_state is wrapped in a #[cfg(test)] block,
so test scaffolding doesn't compile into production builds.
with_override now uses a drop-guard pattern so a panic inside body
still resets TEST_OVERRIDE, preventing stale state from leaking
into subsequent tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the public probe entry point with two override mechanisms:
- In-process TEST_OVERRIDE slot serialised by TEST_LOCK mutex for
unit tests (avoids races with cargo's parallel runner).
- MAGNOTIA_POWER_STATE_OVERRIDE env var for integration tests set
externally.
Platform dispatch: Linux reads /sys/class/power_supply; all others
return Unknown. Five new override tests pass alongside the six sysfs
parser tests from Task 1.2 (12 total green).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pure function takes a directory path and returns OnAc, OnBattery, or
Unknown by reading `type` and `online` files in each supply entry.
Matches the kernel ABI documented at
Documentation/ABI/testing/sysfs-class-power. Failure modes (missing
dir, unreadable files, malformed contents) all fall through to Unknown
rather than panicking.
Seven tests cover: Mains online, battery-only, USB-PD, empty dir,
missing dir, and malformed entries. All pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces crates/core/src/power.rs with the PowerState enum
(OnAc / OnBattery / Unknown) and a unit test confirming the three
variants are distinct. Registers the module in lib.rs and adds
tempfile = "3" to dev-dependencies for use in later tasks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bite-sized TDD plan implementing the design at
docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-design.md.
8 phases, ~13 commits:
- Phase 1: power.rs (PowerState enum, sysfs parser, probe + overrides,
10s TTL cache)
- Phase 2: tuning.rs (Workload enum, helper, battery clamp, GPU clamp,
per-process logging)
- Phase 3: vulkan_loader_available moves from src-tauri to magnotia-core
- Phase 4: whisper backend wires through new helper
- Phase 5: LLM call site wires through new helper after model load
- Phase 6: remove old constants::inference_thread_count facade
- Phase 7: thread_sweep.rs prints 4-panel power-aware RTF table
- Phase 8: manual battery validation smoke
Each task is TDD: write failing test, run, implement, run, commit.
Tests live in unit-test modules inside power.rs and tuning.rs.
Override design uses an in-process with_override(state, |closure|)
helper for unit tests (cargo runs them in parallel; env-var path
would race) and MAGNOTIA_POWER_STATE_OVERRIDE env var for
integration tests like thread_sweep.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec for two new clamps on the existing inference_thread_count helper:
- Battery clamp: drop to physical/2 when on battery (Linux sysfs probe;
macOS/Windows return Unknown, treated as OnAc).
- GPU-offload clamp: 2 threads for fully-offloaded LLM, 4 for Whisper
(Whisper keeps mel spectrogram, decoder bookkeeping, and beam search
on CPU even with full Vulkan offload).
Codex + online-research consult: no prior art in llama.cpp, Ollama,
Jan, or mistral.rs. Apple's Low Power Mode is OS-level (P-core
frequency cap), not a published software API. Lumenote would be first
in the open Rust inference-wrapper space; instrumentation matters,
hence the thread_sweep.rs extension + per-process INFO log.
Architecture (Approach B):
- New crates/core/src/power.rs (PowerState, sysfs probe, 10s TTL).
- New crates/core/src/tuning.rs (Workload enum, helper).
- Move vulkan_loader_available() from src-tauri to magnotia-core
so crates/transcription can call it without a Tauri dep.
- Existing constants::inference_thread_count() becomes deprecated
facade for one commit, then removed.
GPU-offload detection is intent-based (use_gpu && requested_layers
>= model.n_layer() for LLM; cfg(whisper-vulkan) && libvulkan
resolvable for Whisper). Residency-based detection deferred:
llama-cpp-2 0.1.145 doesn't expose post-load offload count, so a
true outcome flag would need llama_log_set callback parsing or an
upstream PR. Caveat documented in the spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundled work from a low-end laptop (Ryzen 5 4650U / Vega 6 / Linux Mint
22.2 / X11) profiling pass.
perf: WEBKIT_DISABLE_DMABUF_RENDERER=1 default on all Linux
Previously only set on Wayland sessions. Empirically it's a
significant idle-cost win on integrated GPUs in either session type:
env-var matrix (release binary, 75s settle, 10s jiffies CPU sample)
showed magnotia idle CPU 12.30% → 2.80% of one core and idle GPU
17% → 10% on this hardware. Users can opt back in by exporting
WEBKIT_DISABLE_DMABUF_RENDERER=0. The Wayland-only XWayland
fallback (GDK_BACKEND=x11, WINIT_UNIX_BACKEND=x11) is unchanged.
fix: secondary-windows ACL — allow set-always-on-top
The float window's pin toggle calls setAlwaysOnTop() but the
secondary-windows capability didn't permit it, so the popout was
stuck always-on-top regardless of the pin state. Adds the
core:window:allow-set-always-on-top permission. Narrow scope.
fix: guard registerGlobalHotkey against non-main webviews
Cross-window settings sync via localStorage can re-fire the
$effect(() => settings.globalHotkey) callback inside popout webviews
where the main layout's registerGlobalHotkey is reachable. Adds an
early-return when the current window label is not "main", so the
popout doesn't trigger an ACL-denied register/unregister and the
user no longer sees a spurious "Hotkey not registered" toast when
popouts are open. Keeps the global-shortcut perm scoped to main.
build: pin @tauri-apps/api 2.10.1 + @tauri-apps/plugin-dialog 2.7.1
Match the Rust crate versions tauri-cli's version-mismatch check
enforces during release builds. Without this, `npm run tauri build`
exits 0 silently while emitting an Error and never producing
binaries.
test: add crates/transcription/tests/jfk_bench.rs
Reproducible RTF regression fixture. Env-gated on
MAGNOTIA_WHISPER_TEST_MODEL + MAGNOTIA_WHISPER_TEST_AUDIO so it
never runs in CI without setup. Loads the JFK WAV inline (no hound
dep), times model load + cold + warm transcribe, prints SUMMARY.
Baselines on this hardware:
--release --features whisper: cold RTF 0.054, warm 0.050, RSS 125 MB
--release --features whisper,whisper-vulkan: cold RTF 0.029, warm 0.028, RSS 125 MB
Vulkan on RADV/Vega 6 nearly halves transcription latency for
Whisper Tiny — useful baseline for Phase 10 hardware-recommendation
scoring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an async onChange handler rejects, the toggle snapped back to its
previous value silently — screen reader users had no signal that the
action failed, only that the toggle suddenly returned to its original
state. Now aria-invalid="true" exposes the failure on the role=switch
button for 1.5s after rejection, so assistive tech announces the error.
Self-clears via setTimeout; no API change for callers.
Hard-coded rgba(214,132,80,X) shadows were tied to the dark-theme accent
and stayed off-hue in light mode (where the accent is a darker #a3683a).
Six tokens added to app.css @theme and mirrored in the light-theme block
plus design-system/colors_and_type.css:
--shadow-accent-sm 0 0 8px 0.25 alpha Toggle on-state
--shadow-accent-md 0 4px 16px 0.3 ModelDownloader card
--shadow-accent-lg 0 4px 20px 0.3 DictationPage record button
--shadow-accent-glow 0 0 8px 0.4 progress-bar glow
--shadow-accent-pill 0 1px 4px 0.3 SegmentedButton pill
--shadow-accent-raised 0 2px 8px 0.2 FilesPage browse tile
Migrated seven sites: Toggle, SegmentedButton, ModelDownloader (x2),
FilesPage (x2), DictationPage record button, SettingsPage locale pill.
Focus-ring shadows (0_0_0_3px_rgba(...,0.1)) left alone — handled in
parallel via the --accent-shadow-focus token.
@font-face: app.css and colors_and_type.css both declare the same five
fonts. Duplication is unavoidable because preview/*.html loads the
design-system file directly without Vite, so it cannot share the
runtime declarations. font-weight ranges and font-style reconciled to
match exactly (Atkinson 200-800 not 400-700, JetBrains gains font-style:
normal); src URLs intentionally differ (root-absolute vs relative).
Comments added to both copies explaining the contract.
- Extend isInputFocused to also bail when document.activeElement sits
inside a custom widget with role combobox/listbox/radio/switch/menuitem/tab
so single-letter shortcuts like '[' do not collapse the sidebar while
focused on SegmentedButton, ZonePicker, etc.
- Move the transcript font-size CSS variable write from documentElement
to document.body. Consumers inherit body styles, and scoping the write
there avoids invalidating root-level styles every time fontSize changes.
Record button bumped from 40x40 to 48x48 (WCAG 2.5.5 AA requires 44x44; 48 chosen as forgivable oversize for the most-touched control). Inner stop-square and record-circle bumped 14x14 to 16x16, Loader2 16 to 18, to keep the visual ratio.
Recording-status sr-only live region now announces both transitions: "Recording started" on start and "Recording stopped" on stop, with the stop message clearing after 1200ms so the live region does not hold stale text. Transition tracked via a plain let prevRecording (not $state) since it is bookkeeping and must not retrigger the effect. aria-atomic="true" added so the whole region re-reads on update.
Brand guidelines (docs/brand/magnotia-brand-guidelines.md §4) say "Never go
below 12px for any text" and "tertiary text must be ≥18px bold or ≥24px
regular". Audit found 246 sub-12px className sites and 166 cases of
text-text-tertiary paired with body sizes.
Bumped text-[9/10/11px] → text-[12px] across 26 .svelte files in src/lib
and src/routes. Promoted text-text-tertiary → text-text-secondary at body
sizes (12-14px) where context allowed; left placeholder pseudo-states,
ternary-conditional inactive states, and line-through "done" states alone
(8 residual pairings, all decorative).
Affects neurodivergent users on 1366×768 budget hardware most directly.
svelte-check: 0 errors, 0 warnings.
Reduces first-run cognitive load. Save / Copy / Clear / Export now appear only
once the user has a transcript or is recording, sliding in via animate-fade-in
(brand --duration-ui + ease-out-quart). Template and Extract Tasks remain
always-visible because they shape transcription rather than acting on completed
content.
Empty state now leads with the catchphrase as a brand statement
(font-display italic 28px) with the hotkey hint demoted to body-font tertiary.
EmptyState gains an optional headline prop; callers without it render unchanged.
Phase 10b colour push: theme reads as Magnotia, not "subdued generic dark".
Same lightness band as Phase 10a (AA preserved), more chroma across accent,
status tokens, and zone surfaces.
Dark accent: #c98555 -> #d68450 (subtle/hover/glow recomputed).
Light accent: #a06a3e -> #a3683a.
Dark status: success #7ec89a -> #5fc28a, danger #e87171 -> #e85f5f,
warning #e8c86e -> #e8be4a.
Light status: success #2f7549 -> #1f7344, danger #a83838 -> #b32626,
warning #b89a3e -> #a08a1f.
Sensory zones pushed further apart so each zone reads as its own
environment: cave cools toward blue-grey, energy warms toward red-orange,
reset cools toward green. AA on body text verified for every Card surface
in both themes.
Stale accent rgba (201,133,85) replaced with new (214,132,80) across nine
files. --shadow-accent in colors_and_type.css mirrored.
No secondary tint token added: Magnotia's identity is amber-as-the-only-
meaningful-colour. Cheaper to add a second meaningful colour later if a
real need appears than to dilute the discipline now.
Default surfaces, text tokens, and borders untouched per scope.
Six changes, one coherent polish pass on SettingsPage:
1. Autostart toggle dedupe. The 27-line custom button that mirrored
Toggle's markup to dodge a bind:checked race is gone. Toggle's new
async onChange + snap-back covers the OS round-trip; setLaunchAtLogin
re-throws on failure so Toggle reverts checked. autostartSyncing
state retired.
2. AI Assistant Default/Advanced split. Cleanup preset and GPU
concurrency moved into a nested SettingsGroup title="Advanced",
closed by default. The Assistant page now leads with four
first-decision controls (tier, model picker, status, prewarm) and
parks the tuning knobs behind a disclosure. Search keywords cascade
from the Advanced group up to AI Assistant up to AI & Processing.
3. Vocabulary disclosure consistency. The hand-rolled Profiles and
Templates sub-panels (with their own ChevronRight buttons +
showProfiles/showTemplates state) are now nested SettingsGroups.
The count badges live in the description slot ("3 profiles ·
custom vocabulary..."). showProfiles, showTemplates, and the
ChevronRight import retired.
4. Model-download ETA in Settings. Both whisper and LLM download
chips now show "{percent}% · {bytes} / {total} · {duration} left",
reusing formatDuration from utils/time.ts. New formatBytes +
etaSecondsFromPercent helpers in SettingsPage; bytes pulled from
the existing emit payload (DownloadProgress snake_case for whisper,
done/total for the LLM event). FirstRunPage already does the
timestamp-based ETA; we mirror its shape.
5. Privacy badge in sticky header. Single chip "100% local · no
telemetry · no cloud" added next to the Settings heading using
bg-accent-subtle + text-text-tertiary. The longer About list
stays put as the deep-dive.
6. SettingsGroup icons on the seven top-level groups. Mic, BookOpen,
Type, Sparkles, SquareCheck, Clipboard, Sliders — paired with the
existing literal text titles per the icon-with-label brand rule.
Inner nested groups stay icon-free.
Verification: npm run check returns 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both props default to current behaviour, so all existing call sites render
unchanged. Subtle uses bg-bg-elevated for quieter empty-state contexts;
danger uses bg-danger/5 + border-danger/30 for calm caution panels (not
red-alert loud, matching brand). Raised layers shadow-md on top of the
existing low-contrast shadow for floating or detached surfaces.
Title bumped to font-semibold so it scans first; description gets tracking-wide
plus mt-1 for cleaner rhythm. Chevron alignment shifted from mt-0.5 to mt-1 to
match the new spacing. Optional `icon` prop renders a Lucide component between
the chevron and the title block at 16x16, text-tertiary, no colour change on
open. Layout is preserved exactly when no icon is passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the shared Toggle with three optional props while keeping the
existing bind:checked path untouched for current call sites.
- disabled: dims the control, sets aria-disabled, and early-returns on click.
- loading: forces a Loader2 spinner inside the thumb, applies cursor-wait
and aria-busy, and blocks interaction. Cursor-wait wins over disabled's
not-allowed when both are set, matching loading-precedence guidance.
- onChange(next): when supplied, replaces the immediate flip. Promise
returns drive an internal pending flag so the toggle renders loading
while the handler resolves; on resolve checked flips, on reject the
toggle snaps back. Synchronous handlers flip immediately after the call.
Brand motion preserved: 150ms duration, cubic-bezier(0.2, 0.8, 0.2, 1),
no transition-all.
- Add --color-overlay-dim token to app.css @theme (rgba(15,14,12,0.7) dark, rgba(26,24,22,0.55) light); mirror as --overlay-dim in design-system/colors_and_type.css.
- MorningTriageModal: replace inlined rgba scrim with var(--color-overlay-dim), drop backdrop-blur-sm (brand opposes glassmorphism by default), bump container to z-[60] so it sits above the .grain z-50 overlay.
- FilesPage: collapse two near-identical drop-zone blocks into one bordered region with conditional inner content; preserves isDragOver hover behaviour and aria region.
Two scoped fixes to HotkeyRecorder:
- Append "Esc to cancel." to the recording prompt so the existing Escape
handler is discoverable. srStatus already mentions Escape; no harmonisation
needed.
- Replace animate-pulse-warm on the captured state with a static
bg-success/10 + border-success/40 flash. pulse-warm is the
recording-active keyframe; reusing it for the saved state muddled the
visual vocabulary. The global * transition rule already animates
background-color and border-color over --duration-ui (150ms) with the
brand cubic-bezier ease, so no new keyframe is needed and prefers-reduced-motion
is honoured automatically. Also swapped transition-all for transition-colors
to comply with the no-transition-all brand-motion rule.
Native window.confirm() is OS-styled and breaks the warm-amber UI
tone with an abrupt brutalist modal. The brand voice is "calm,
informative, solution-first; never blame the user", and confirm() is
none of those.
Replaced both destructive paths (Clear All, Delete N selected) with
a two-click inline pattern: first click arms the trigger and morphs
it into "Delete X? [Confirm] [Cancel]" inline pills; auto-cancels
after 4s so a user who walked away doesn't return to a primed delete
button. No new modal, no new component, no new dependency.
Both timers are cleared in onDestroy so unmounting the page mid-arm
doesn't leak.
Side-stripe borders (border-left: 2px or 3px in an accent colour, with
the rest of the element having no border) are the textbook AI-coded-IDE
"selected-state" pattern. The brand vocabulary is colour-tint and
chevron, not stripe-and-fill.
Replaced eight sites:
- VirtualSegmentList active/match/default segment rows: bg-accent/10,
bg-warning/10, hover:bg-hover (drop the stripes; bg-warning bumped
/5 → /10 so it's visible without the stripe doing the work).
- viewer/+page.svelte: same pattern as VirtualSegmentList.
- TasksPage profile-list tabs: bg-accent/10 + font-medium for active,
hover:bg-hover for inactive. rounded-md added so the tint follows
the row outline.
- ToastViewport: replaced border-left + variant colour with full 1px
border + bg tint + border-color tint (color-mix() with the matching
semantic token at 35% / 10%). Also removed the orphan --moss /
--signal / --ember tokens — they were not defined in app.css and
fell back to hex literals.
- preview/+page.svelte: dropped the phase-coloured left stripe and
the borderColorClass derivation; the header already has a pulsing
dot / animated bars / spinner for each phase, so the stripe was
redundant.
Per the project's no-dashes feedback rule (see CORBEL-Main memory
feedback_no_dashes.md), em-dashes in user-visible copy are the single
biggest "AI wrote this" surface tell. Forty-two occurrences across ten
files: ten in SettingsPage's group descriptions and option labels
(audio device picker, vocabulary help, prompt placeholders, model
descriptions), four in FirstRunPage's setup steps, three in
DictationPage / FilesPage / Energy chip tooltips, plus the privacy
bullets ("100% offline, no Python required, no cloud, no accounts,
no telemetry"), the rejection toast, MicroSteps thumb labels, and the
shutdown ritual prompts.
Replacement rule: subordinate-clause em-dashes followed by lowercase
become commas; sentence-break em-dashes followed by capitals become
full stops. Code comments left intact.
One non-em-dash regression caught while reviewing: TasksPage's energy
filter "no selection" placeholder showed "—" as a literal label; the
script changed it to a comma which read as a UI bug. Replaced with
"Any" instead.
The settingsSearch state was declared in Phase 9c with a comment
describing the intended behaviour but no <input> bound to it. With 21
SettingsGroup instances and a 2,261-line page, finding any specific
control required scrolling and opening every group. For the
neurodivergent audience this is exactly the cognitive-load failure
the brand commits to avoiding.
Added a sticky search input at the top of the page (paired with the
"Settings" title in a single sticky header that bg-bg-matches the
page so groups don't bleed under it). Each SettingsGroup now passes
through searchMatches against its title, description, and a curated
keyword string. Parent groups inherit their children's keywords so a
search for "GPU" expands AI & Processing and the AI Assistant inner
group together.
Updated SettingsGroup to push prop changes to the DOM via $effect:
the native <details> element's open attribute is mutated by user
clicks outside Svelte's reactive graph, so a parent prop change
needs to force-sync. User-driven toggles where the prop value didn't
change keep their local state because $effect only re-runs on diff.
Verified live: baseline shows 3 groups open (Transcription, Terms &
profiles, Capture & export), "GPU" opens AI & Processing + AI
Assistant only (other 19 closed), "ritual" opens Tasks & Rituals +
Rituals only, X-button clear restores baseline.
Eighteen sites across SegmentedButton, Toggle, HotkeyRecorder,
ModelDownloader, FilesPage, and SettingsPage hardcoded the old amber
rgba(232,168,124,...) for focus rings, glows, and progress bars. After
the Phase 10a a11y darkening of --accent to #c98555, the inline shadows
no longer matched the accent fill they were paired with, so every
focused input rendered a glow at a noticeably different hue from its
border.
Replaced all of them with the current accent rgba(201,133,85,...).
Toggle component also had two motion violations: an active:scale-95
press-shrink and a cubic-bezier(0.16,1,0.3,1) (ease-out-expo) thumb
transition. Both replaced with the brand's --ease curve
(0.2,0.8,0.2,1) and the scale dropped, in line with the record-button
fix in the previous commit.
Removed redundant inline transition-duration overrides on FilesPage
Browse button (the global * rule already sets --duration-ui) and on
ModelDownloader's primary CTA (also dropped its active:scale-[0.97]).
Record button had active:scale-[0.93] transition-all, which contradicts
the brand motion guideline ("never bounce, slow, calm, deliberate"). The
record button is the most-touched control in the app; a 7% press-shrink
on it sets the wrong tone for everything else.
Removed the scale transform, removed the inline transition-duration
override (the global * rule in app.css already animates the named
properties at --duration-ui). Also updated the inline shadow rgba from
(232,168,124,0.3) to (201,133,85,0.3) so the glow tracks the new
a11y-darkened accent fill instead of the pre-Phase-10a amber.
Body had user-select:none globally so users could not copy error
strings, model paths, profile names, transcripts, status messages,
or any text into search or another tool. The brand essence is
"clarity without friction" and this was friction.
Body now defaults to user-select:text (the standard for text content),
and the chrome elements that should never be drag-selected (button,
summary, role=button|switch|tab|menuitem, data-no-select) opt out.
Verified live: body=text, button=none, paragraph=text (inherited).
The runtime app reads tokens from app.css @theme; this file ships values
to the buildless preview pages under design-system/preview/*.html.
Several values had drifted from the Phase 10a a11y darkening done in
app.css, so the previews showed brighter accents and lighter tertiary
text than what users actually see.
Aligned: dark --text-tertiary, dark --accent (+ hover/subtle/glow/
border-focus/shadow-accent), light --text-tertiary, light --accent
(+ hover/subtle/glow), light --success, light --danger.
Header banner now states the file's role explicitly so future edits
don't recreate the drift.
The Tailwind v4 @theme block defines --color-danger as the semantic error
token, and 15+ files across the codebase use text-danger consistently.
SettingsPage was the lone outlier with five text-error / border-error
sites (audio devices, profile delete, vocabulary, term delete, diagnostic
report). Without a --color-error token, those utilities resolved to the
default text colour, so error messages rendered in warm cream instead of
red. Verified live via Playwright probe before and after.
README LLM-formatting section now states four Qwen tiers (Qwen3.5 2B /
4B / 9B + Qwen3.6 27B) and the magnotia-llm crate row reflects the
four-tier registry. The whisper-ecosystem context doc gets the same
refresh and cites unsloth as the GGUF source.
Older roadmap and Phase-0 audit docs left untouched — they are dated
historical artefacts and rewriting them would muddy the audit trail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up the registry rename in the front-end and Tauri command layer:
- src/lib/types/app.ts: LlmModelIdStr now lists the four new ids
(qwen3_5_2b / qwen3_5_4b / qwen3_5_9b / qwen3_6_27b).
- src/lib/pages/SettingsPage.svelte: LLM_MODELS table rebuilt with
four tiers (Minimal / Standard / High / Maximum), matching subtitles
and download-size copy. selectedLlmModelId fallback, hardware-warning
thresholds, tier-availability check, and ensureRecommendedLlmTier
fallback all retargeted at the new ids. The Maximum tier surfaces a
64 GB / 24 GB warning so users with mid-range hardware see honest
expectations.
- src-tauri/src/commands/llm.rs and commands/tasks.rs: doc-comment
examples refreshed (Qwen3 4B → Qwen3.5 4B, Qwen3's tokenizer →
Qwen's tokenizer — the BPE family is shared).
- src/lib/stores/llmStatus.svelte.ts: chip-detail example updated.
cargo build --workspace clean. cargo test --workspace clean.
npx svelte-check reports one pre-existing error in vite.config.js
(unused @ts-expect-error directive, dates back to the original
scaffold commit 9926a42); not introduced here, out of scope to fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the three older Qwen3 variants with a four-tier ladder spanning
a wider hardware range:
- Qwen3_5_2B_Q4 (Minimal, 8 GB RAM, ~1.3 GB download)
- Qwen3_5_4B_Q4 (Standard, 16 GB RAM / 6 GB VRAM, ~2.7 GB) — DEFAULT
- Qwen3_5_9B_Q4 (High, 32 GB RAM / 12 GB VRAM, ~5.7 GB)
- Qwen3_6_27B_Q4 (Maximum, 64 GB RAM / 24 GB VRAM, ~17 GB)
All four GGUFs sourced from unsloth's HF org with pinned commit SHAs.
Sizes and SHA256 hashes verified against the live X-Linked-Etag /
X-Linked-Size headers on the LFS CDN. Q4_K_M quantisation throughout
(common sweet-spot for cleanup + task extraction).
recommend_tier rewritten to span four bands; default_tier moves from
the old 4B-Instruct-2507 to Qwen3.5 4B. The 27B Maximum tier honestly
needs 64 GB RAM to run without partial offload — surfaced in the
description string so the Settings UI can warn realistically.
In-tree smoke tests (smoke.rs, content_tags_smoke.rs) updated to
reference the new smallest tier so a developer's MAGNOTIA_LLM_TEST_MODEL
points at the cheapest GGUF to download. Crate description in
crates/llm/Cargo.toml refreshed to mention the new family.
NOTE (out of scope; not fixed): the size_bytes / sha256 / hf_url
methods could collapse into a single LlmModelMetadata table to remove
four parallel match arms. Layer 2 cleanup, separate session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0 update: replaces the loose "quick wins" list with a structured
Fix Areas section organised into impact tiers (A: high-impact README
truth fixes, B: low-effort self-violation fixes, C: structural smells
deferred to Phase 2, D: hygiene). Each task names the file, the change,
the verification command, and the effort estimate.
New playbook: docs/audit/phases-1-8-playbook.md — step-by-step
acquisition-grade audit procedure for the remaining seven phases. Each
phase has goal, inputs, procedure (with concrete commands), deliverable,
acceptance criteria, and time estimate. Designed to be picked up
independently from any phase.
Inventory of workspace shape, LOC, dependency graph, public API surface,
external surfaces (Tauri commands / MCP tools / frontend routes), and a
README↔code drift log. Input for Phase 1 (lean-pass).
Surfaces five concrete README drifts (one HIGH: stores list fiction; two
MED: undocumented Tauri modules and a Moonshine claim with no registry
entry) and three structural smells worth a Phase-2 follow-up.
Replace all instances of the legacy product names "Kon" and "Corbie" with
"Magnotia" across user-facing copy, code identifiers, package names, bundle
ids, file paths, and documentation. Preserves the unrelated "konsole" (KDE
terminal) reference and the parent CORBEL company name.
- Renames 10 Rust crates (kon-* → magnotia-*) and the tauri binary
- Updates package.json, tauri.conf.json (productName + identifier)
- Renames CSS classes (kon-rh-* → magnotia-rh-*) and animations
- Renames brand and roadmap docs
- Regenerates Cargo.lock and package-lock.json
Verified: svelte-check passes; pure-rust crates compile under new names.
Final impeccable detect cleanup. The launch-at-login toggle thumb in the
Tasks & Rituals section was using cubic-bezier(0.34, 1.56, 0.64, 1) — the
same overshoot bounce that was replaced in Toggle.svelte by commit
6469663. Match the convention here so all toggle animations use the
same exponential ease.
Single-line CSS change. Visual smoke not exercised because the dev
server is winding down between subagents; npm run build confirms the
file still compiles clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 9c follow-up. The 2309-line hand-rolled accordion is replaced with
seven SettingsGroup-wrapped top-level groups, each containing the
relevant existing sub-sections as nested SettingsGroups:
1. Audio — microphone (input device).
2. Vocabulary — terms & profiles, profiles & templates (legacy manager
moved in here so all profile / vocabulary state lives together).
3. Transcription — engine, format mode, model management, compute device,
language. Defaults to open to preserve the prior accordion's landing.
4. AI & Processing — post-processing toggles, AI Assistant tier and
model management, if-then implementation rules. AI Assistant and
if-then rules moved out of their original positions to sit alongside
the deterministic post-processing toggles.
5. Tasks & Rituals — rituals (incl. launch-at-login, kept bundled with
the existing Rituals UI block to avoid splitting that block), tasks
page (sparkline), nudges.
6. Output & Capture — read aloud (TTS, lazy-loaded on first open via the
new SettingsGroup `onopen` hook), capture & export.
7. Appearance & System — global hotkey, appearance (theme/zone/font/
locale), accessibility, about (engine status + diagnostics).
Deviations from Codex's 7-group spec:
- Launch-at-login stays inside the Rituals sub-group (was bundled there
in the existing markup; relocating would require splitting the
Rituals UI block, which is out of scope for this pass).
- Profiles & Templates legacy manager pulled into the Vocabulary group
rather than appearance/system.
Implementation notes:
- SettingsGroup gains an optional `onopen` callback prop, fired once on
the first closed→open transition. Used by Read aloud to lazy-load TTS
voices and by AI & Processing to refresh LLM status. Replaces the
former toggleAiSection / toggleReadAloudSection imperative handlers.
- The centralised openSection state is removed; each <details> manages
its own disclosure.
- Tokens are inherited from app.css; the prior global token darkening
(commit 2da0a5b) covers all section labels via the existing utility
classes — no per-component label-class swaps were added.
Search box deferred to a follow-up commit. Forcing `open=true` on
SettingsGroup based on a filter input would require either a controlled
`open` prop or a {#key} re-mount strategy, both of which add risk to
this restructure pass.
Tauri-bridged commands cannot be exercised in browser-only `npm run
dev`; structural verification done via npm build, npm check, and a
live dev server fetch of /settings. Real persistence smoke needs a
tauri dev session.
Verification:
- cargo fmt --check: pre-existing whitespace diffs in src-tauri/src/
live.rs and src-tauri/src/lib.rs only (untouched by this commit).
- cargo clippy --all-targets -- -D warnings: clean.
- cargo test: 283 tests pass.
- npm run check: 1 pre-existing error in vite.config.js:5; 0 new.
- npm run build: clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical fixes from `npx impeccable detect`:
- Sidebar.svelte: replace `transition: width, min-width` on the aside
with a wrapping CSS-grid container animating `grid-template-columns`.
Avoids per-frame layout cost from animating `width` directly.
- MorningTriageModal.svelte: swap pure `bg-black/50` overlay for the
brand deep-neutral `rgba(26, 24, 22, 0.5)` (#1a1816 @ 50%). TODO left
in source to promote this to a `--color-overlay` token in app.css.
- Toggle.svelte: drop bouncy `cubic-bezier(0.34, 1.56, 0.64, 1)`
(1.56 overshoot) for ease-out-quart `cubic-bezier(0.16, 1, 0.3, 1)`.
Still snappy, no overshoot — better fit for a toggle.
- TasksPage.svelte: same grid-template-columns refactor as Sidebar
for the list-sidebar `transition: width` declaration.
Verification:
- npm run check: 1 pre-existing error (vite.config.js:5), 1 unrelated
warning in SettingsGroup (out of scope, owned by parallel subagent).
- npm run build: clean.
- cargo clippy --all-targets -- -D warnings: clean (with LIBCLANG_PATH).
- cargo test --workspace: 283 passed, 0 failed.
- cargo fmt --check: pre-existing diffs in main.rs / lib.rs (no Rust
files were touched in this commit).
Manual smoke deferred — `npm run dev` is in use by the SettingsPage
subagent, so the build-clean signal is the proxy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 10a a11y audit (2026-04-29):
T4: Energy radio chip text in dark mode read at 3.48:1 (text-text-tertiary
on chip bg). Bumped non-selected chips to text-text-secondary so the
hierarchy still reads but the resting state clears AA.
T7: Selected energy radio (and the match-my-energy toggle next to it)
used bg-accent/15 — visually indistinguishable from surrounding bg in
dark theme. Bumped to bg-accent/25 and added a 1px accent/30 border on
the selected state so the selected pill is unambiguous in both themes.
H3: Per-row bulk-select checkbox in HistoryPage rested at opacity-50,
which drops the unchecked outline below 3:1 over bg-bg-card. Bumped
base opacity to 70%; hover/selected states unchanged.
Note on T6 (energy radiogroup arrow keys): verified the keyboard handler
in TasksPage. It is attached to the wrapper element with role=radiogroup,
and arrow-key events on the focused radio child bubble up to the wrapper
because the focused radio sits inside it. The handler reads
settings.currentEnergy (not the focused element) to pick the next index,
then focuses the new radio explicitly via querySelector. ArrowRight /
ArrowLeft / ArrowUp / ArrowDown / Home / End all wired correctly. No
change needed — pattern is sound.
Resolves: T4, T7, H3. T6 verified working as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 10a a11y audit (2026-04-29) flagged the morning triage modal as
having role="dialog" and aria-modal="true" but no focus trap, so Tab
leaks out to the sidebar/page beneath. Escape-to-close was already
wired and is preserved.
Behaviour now matches the W3C dialog pattern:
- On open, capture the invoking element (document.activeElement) and
move focus to the first focusable inside the dialog.
- Tab cycles forward; Shift+Tab cycles backward. Wrap-around between
first and last focusable.
- On close, restore focus to the captured invoker.
- The dialog container itself gets tabindex=-1 so it can hold focus
if no children are focusable (e.g. during the loading state).
The focusable selector excludes aria-hidden elements and elements with
no offsetParent (display:none / visibility:hidden), so dynamic content
between loading -> tasks -> action buttons stays in sync.
Resolves: G4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 10a a11y audit (2026-04-29) flagged seven inputs across the app
that strip the global 2px :focus-visible outline (defined in app.css:251)
without providing a comparable replacement. Net effect: keyboard users
see at most a 1px border-colour shift on focus, sometimes nothing.
The fix removes the focus:outline-none override so the global rule
applies. Affected inputs:
- FilesPage: file-transcript textarea (F2).
- TasksPage: search input, quick-add input, inline list-edit, new-list
input (T1, T2, T3, plus the new-list rename input).
- HistoryPage: top search, inline title rename, tag-add (H1).
- ImplementationRulesEditor: trigger/surface/task selects + speak-line
input (S7).
- TaskSidebar, WipTaskList: quick-add inputs (S8).
The 1px focus:border-accent on inputs that had it is retained as a
secondary cue; the global 2px ring is now the primary indicator and
matches button focus across the app.
Resolves: F2, T1, T2, T3, H1, S7, S8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 10a a11y audit (2026-04-29) flagged 14 contrast failures rooted in
four tokens. Single token-level change resolves them across both themes.
P1 (text-text-tertiary): #8a8578 → #6b6557 light, #716b60 → #8c8678 dark.
Token was used as both decorative tertiary and body-grade label, so it
must clear AA 4.5:1. Lifts ratios from ~3.3-3.7:1 to ~4.7-5.0:1 across
the surfaces it appears on (sidebar tagline + footer, dictation footer,
files hint, tasks empty state, history empty state, settings descriptions
and section labels, shutdown trail copy, first-run skip links).
P2 (color-accent): #b87a4a → #a06a3e light, #e8a87c → #c98555 dark. White
text on accent fill (Browse Files button, selected segmented pills,
link-style buttons) now clears AA. Dark theme worst case was 2.03:1 on
Browse Files; the new dark accent clears 4.5:1 with white. Subtle/hover/
glow tokens recomputed off the new bases.
G3 (color-success light): #3d8a5a → #2f7549. Sidebar Ready status text
and other success copy on cream surfaces clears AA.
FR3 / D4 (color-danger light): #c44d4d → #a83838. Browser-mode warning
and hardware-probe error copy clears AA on cream.
Resolves: G1, G2, G3, D1, D2, D3, D4, F1, F3, F4, T5, H2, S1, S2, S3,
SD2, FR2, FR3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues in flush_is_idempotent_and_leaves_clean_state from
581a098:
1. silence_close_samples and max_chunk_samples were cast `as u64`
but with_thresholds takes usize — wouldn't compile.
2. enter_threshold was 0.005 and exit_threshold 0.01, which
violates the hysteresis invariant (enter must be >= exit) and
panics in debug_assert at runtime. Swap to 0.01 / 0.005 so the
test actually runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small Phase 1 follow-ups for the Android target:
1. tauri.conf.json: add `bundle.android.minSdkVersion: 24`. Android 7.0
is the floor — gives us Vulkan availability (for the eventual GPU
feature flag), AAudio for cpal, and is what Pixel-class hardware
tests against. Keeps the global `identifier` on `uk.co.corbel.kon`
for now; the Corbie rebrand sweep will land `corbel.technology.corbie`
as a single coherent commit.
2. src/lib/utils/runtime.ts: add `isAndroid()` and `isMobile()` helpers
alongside the existing `hasTauriRuntime()`. Both use UA sniffing —
sufficient for feature-gating UI, never for security decisions.
These are how the Svelte side will hide:
- hotkey config (no global hotkey API on Android)
- paste-mode picker (auto-paste maps to a copy-only flow)
- meeting auto-capture toggle (process list unavailable)
- multi-window buttons (open-viewer, open-float, etc.)
- system-tray-related affordances
Tauri 2 doesn't expose a synchronous platform-detection helper that
works during initial render, so UA sniffing is the pragmatic choice.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
Phase 1 of the Android same-repo target plan: make the workspace
compilable for `aarch64-linux-android` (and the other NDK ABIs) by
removing the desktop-only crate dependencies and command bodies from the
Android build. After this commit, `tauri android init` followed by
`cargo tauri android build` is structurally unblocked — the remaining
work is the SDK/NDK toolchain (off-sandbox), the Svelte single-window
refactor, and the Phase 3 MVP feature surface.
What's gated under `cfg(not(target_os = "android"))`:
- src-tauri/Cargo.toml: `tauri = { features = ["tray-icon"] }` is now
declared in the desktop-only target block. The `global-shortcut`,
`window-state`, and `autostart` plugins join it — none of the three
support Android natively. The base `tauri = "2"` plus `dialog`,
`opener`, and `notification` plugins remain unconditional because they
do support Android.
- src-tauri/src/lib.rs: `mod tray` declaration, the matching
`tray::setup(app)` call, the close-to-tray `WindowEvent::CloseRequested`
handler, and the `.plugin(tauri_plugin_global_shortcut::*)` /
`_autostart` / `_window_state` chain are all desktop-only. The
builder is split with a single `#[cfg(not(target_os = "android"))]`
branch that adds the desktop plugins on top of the universal base.
- src-tauri/src/commands/tts.rs: `tts_speak` previously had three
`#[cfg(target_os = ...)]` branches but no fallback, so on Android the
`spawned` binding was unbound and the function failed to compile.
Mirrored the existing `paste.rs` not-implemented fallback. Same fix
for `list_voices_impl`. Frontend will hide the Read Page Aloud button
on Android via `isAndroid()`.
- src-tauri/src/commands/windows.rs: all four multi-window commands
(`open_task_window`, `open_preview_window`, `close_preview_window`,
`open_viewer_window`) get an Android stub that returns a clear
"Multi-window is not supported on Android" error. Tauri on Android
is single-Activity; the previously-secondary content (preview overlay,
transcript viewer, task float) will live as routes inside the main
window, gated by `isAndroid()` on the frontend.
What's *not* changed:
- Top-level `identifier` in tauri.conf.json stays `uk.co.corbel.kon`.
The Phase 10b Kon → Corbie rename sweep will land
`corbel.technology.corbie` as part of a coherent rebrand commit
rather than fragmenting the rename across this branch.
- `bundle.android.minSdkVersion: 24` added so a future
`tauri android init` knows to target Android 7.0+ (Vulkan available,
scoped storage starts at 29 — we'll surface scoped-storage paths
via Tauri's dialog plugin on Phase 3).
- `kon-hotkey` already exports a non-Linux stub; no changes needed.
- `commands/meeting.rs` still calls `process_watch::list_running_process_names()`
which compiles on Android but returns an empty list (SELinux blocks
/proc walk on API 24+). Frontend will hide the toggle on Android.
Verification: 91/91 tests still pass on the buildable-in-sandbox crates
(kon-storage 60, kon-core 16, kon-mcp 9, kon-hotkey 4, kon-cloud-providers
2). svelte-check 0/0 across 3957 files. The src-tauri crate itself can't
be compiled in this sandbox (no webkit2gtk); CI's desktop builders will
exercise the desktop branch, and Jake's Android-equipped dev box will
exercise the Android branch via `tauri android init`.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
Both `kon-transcription` and `kon-llm` previously hardcoded their native
acceleration features in Cargo.toml — `whisper-rs` with `vulkan`,
`llama-cpp-2` with `openmp` + `vulkan`. That worked everywhere desktop
ships (Linux/macOS/Windows all have Vulkan via MoltenVK on Mac), but it
made an Android build structurally impossible: NDK builds against drivers
that vary wildly across SoCs (Adreno OK, Mali patchy, PowerVR worse), and
some older devices have no Vulkan at all.
Roadmap step 0 from the Android plan: make the GPU acceleration
opt-in so a CPU-only target compiles. Reuses the existing pattern that
README's "future Windows non-AVX2 build" comment hinted at.
- kon-transcription: new `whisper-vulkan` feature gates `whisper-rs/vulkan`
via the optional-syntax `whisper-rs?/vulkan`. Default features stay as
`["whisper", "whisper-vulkan"]` so desktop is unchanged.
- kon-llm: new `gpu-vulkan` and `openmp` features each gate the matching
`llama-cpp-2` feature. Default stays `["gpu-vulkan", "openmp"]`. They are
independent so an Android Vulkan build can opt into vulkan without
openmp (NDK OpenMP linking has known cross-version fragility).
CPU-only build invocations:
cargo build -p kon-transcription --no-default-features --features whisper
cargo build -p kon-llm --no-default-features
Verified: all 91 tests in the buildable-in-sandbox crates still pass.
The two crates whose Cargo.toml changed (kon-transcription, kon-llm)
can't be compiled in this sandbox (ort-sys CDN + cmake-built llama.cpp);
CI's Linux/macOS/Windows builders will exercise the default-feature path
exactly as before.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
`emit_live_result` already detected a lost result_channel listener: it
sent a one-shot status warning and from then on short-circuited future
result sends. But if the status_channel listener was also gone — which
is what happens when the user closes the main window without calling
stop_live_transcription_session — the worker kept polling inflight
inference every 10 ms forever, holding a model loaded on the GPU and
keeping the WAV writer file handle open until the process exited.
When the warning send to status_channel also returns Err, the entire
frontend channel pair is dead. Self-assert stop_flag from inside
emit_live_result so the worker drains and exits cleanly. Existing user-
initiated stop semantics are unchanged.
- Threaded `stop_flag: &Arc<AtomicBool>` through `emit_live_result` and
the free `poll_inference` (instance method already had access via
`self.stop_flag`).
- Existing `result_listener_loss_is_warned_once_*` test updated to pass
a stop_flag and assert it stays false when only result_channel fails.
- New test `dead_result_and_status_channels_self_assert_stop_flag` proves
the self-stop fires when both channels Err.
(src-tauri doesn't build in the audit sandbox — needs webkit2gtk; CI
cross-platform compiles it.)
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
The evdev listener's run loop did `let _ = event_tx.send(event).await`
inside the trigger-key match arm. If the receiver was dropped without
the explicit shutdown signal (set hotkey to None), the send returned
Err and the loop kept polling — sending into a closed channel forever
until something else terminated the task.
Replace with explicit handling: on Err, log via log::warn! once and
return Ok(()) from `run`. The shutdown-via-None path is unaffected.
kon-hotkey still 4/4.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
The cpal stream-error closure used `let _ = err_tx.try_send(...)` against a
bounded sync_channel(16). If the live session's listener stalled or the
frontend disconnected, runtime stream errors were silently dropped — the
diagnostic bundle showed nothing for a session that mysteriously stopped
working.
- Bump the error channel capacity 16 → 32 (matches AUDIO_CHANNEL_CAPACITY).
- On try_send failure, log to stderr with the device name + a per-session
drop counter so the symptom is visible in the diagnostic bundle even
when the typed event never reached the frontend.
- Plumb a new `dropped_errors: Arc<AtomicU64>` through `build_input_stream`
alongside the existing `dropped_chunks`, mirroring the same pattern.
(kon-audio doesn't build in the audit sandbox: it links against ALSA
which the sandbox lacks. CI cross-platform compiles it.)
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
The earlier audit noted that `flush()` had three exit paths but only two
of them explicitly cleared all state-machine fields:
1. InSpeech with non-empty active_chunk: emit_active_chunk_and_close()
handled it.
2. InSpeech with empty active_chunk (hit_max-mid-flush): handled inline.
3. Idle (no padded frame, or padded frame closed cleanly): no explicit
reset — silent_tail_samples / pending_onset_frames / onset_buffer
could carry stale values from `consume_frame` calls inside the same
flush.
In the worst case, the first feed of a fresh recording could see leftover
onset bookkeeping and produce a chunk start that doesn't match the new
session's audio. Reusing the same `RmsVadChunker` across stop/start is
the main path that would hit this.
Add a single defence-in-depth reset block at the end of flush — every
exit path lands the chunker in the same fields a fresh chunker has,
except `next_sample_index` (the running total-samples counter, intent-
ionally preserved). Test asserts: a second flush after a full speech →
silence → partial-pending sequence emits zero chunks, and a subsequent
silent feed also emits zero, proving no stale state leaked.
(kon-transcription doesn't build in the audit sandbox because ort-sys's
build script can't reach pyke's CDN; CI cross-platform compiles it.)
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
The error_log table had no retention policy: every backend error was
appended forever, so across months of dogfooding it grew unbounded. That
silently bloats the diagnostic-bundle export and slows the
list_recent_errors query the Settings → About panel runs.
- New `kon_storage::prune_error_log(pool, keep_days)` does a single
`DELETE FROM error_log WHERE timestamp < datetime('now', '-Nd days')`
and returns the row count removed.
- src-tauri/src/lib.rs runs it once during setup() with a const
ERROR_LOG_RETENTION_DAYS = 90. Failure is logged to stderr but does not
block startup — a prune that fails is strictly less important than the
app coming up.
- Test: insert three rows at now / -30d / -200d, verify a 90-day prune
removes only the oldest, and a subsequent 14-day prune removes the
-30d row. Storage suite at 60/60.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
The Phase 9 bulk export utility had a single success toast that was emitted
even when zero of N writes succeeded — "Exported 0 of 5 transcripts to
folder/" reading like the user just deliberately exported nothing.
Branch on the result count:
- 0 of N: error toast pointing at the console for write failures.
- N of N: success toast.
- M of N: warn toast — partial export, with the same console pointer.
Single-file save (`saveTranscriptAsMarkdown`) was already correct:
explicit success on save, error on failure, silent on user-cancelled —
left untouched.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
Previously the 15-second meeting-detection setInterval was started in
onMount unconditionally (when Tauri runtime was available). When
`settings.meetingAutoCapture` was disabled the callback still fired every
15 s, just to early-return — burning a wakeup that did no useful work
and confusing "is this firing? did the toggle take effect?" debugging.
Move the timer into a `$effect` whose only tracked dependency is
`settings.meetingAutoCapture`. Toggling off now clears the interval; toggling
on creates a fresh one. Reads of `meetingAutoCaptureApps` and `globalHotkey`
happen inside the interval callback (post-setup) so they don't trigger the
effect to tear down on every keystroke in the apps editor.
The `meetingCapturePoller` variable and its `onDestroy` cleanup are gone —
the effect's own cleanup return takes care of it on unmount.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
Error toasts are sticky (duration: 0) so a misbehaving command that fires
errors in a loop — a backend that flaps, a polling effect over a broken
endpoint — accumulates toast items in the in-memory store indefinitely.
The audit found no other unbounded $state arrays in the frontend stores
(history caps at 500, recentNudges prunes by time, tasks/taskLists
replace-on-load), but `items.push(toast)` had no upper bound.
Add MAX_TOASTS = 50 with FIFO eviction. Doesn't change behaviour for
realistic toast volumes; only kicks in if something is genuinely wrong.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
HistoryPage previously serialised the full TranscriptDto — text, segments,
manual + LLM tags, audio path — into `localStorage["kon_viewer_item"]` so
the viewer window could pick it up on mount. On a multi-hour transcript
that's MB-scale of user voice content sitting in storage that any
same-origin script in any open Kon window can read.
Hand off only `{ id }` (and a timestamp on re-saves). The viewer fetches
the canonical row from SQLite via the existing `get_transcript` Tauri
command and hydrates via the now-exported `mapTranscriptRow`. Cross-window
sync via the `storage` event still works — the receiving window re-fetches
on event instead of trusting the payload.
- HistoryPage `openViewer` + `openEditor`: write `{ id }` only.
- viewer `onMount` + `handleStorageChange`: route through new
`loadFromHandoff` which calls `invoke("get_transcript", { id })`.
- viewer `saveItemToHistory`: re-stamp localStorage with `{ id, stamp }`
to retrigger the storage event in sibling windows without leaking
content.
- `mapTranscriptRow` exported from page.svelte.ts for the viewer's use.
Backward-compatible at the parse layer: the `{ id }` shape extracts cleanly
from a stale full-DTO payload (TranscriptEntry already carries `id` at top
level), so a session that survives the upgrade picks up the new path on
next handoff without manual cleanup.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
`detect_meeting_processes` is called every 15 s when meeting-auto-capture
is enabled. The previous `list_running_process_names` allocated a fresh
`sysinfo::System` per call and walked /proc cold; on a busy host
(~300 processes) that's ~50–100 ms of work, every poll, forever.
Add `kon_core::process_watch::ProcessLister`, a thin wrapper around a
long-lived `System` whose process table is refreshed in place. The Tauri
host holds one behind a `Mutex<ProcessLister>` in a new `MeetingState`
managed at app setup. The free `list_running_process_names` is kept as a
convenience that constructs a fresh `ProcessLister` per call — its only
remaining caller is the existing smoke test.
- ProcessLister + Default in crates/core/src/process_watch.rs.
- MeetingState in src-tauri/src/commands/meeting.rs; the command takes
it via `tauri::State` and locks for the duration of the snapshot.
- src-tauri/src/lib.rs registers MeetingState alongside the other
managed states.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
Migration v15 adds a composite index covering the dominant transcripts
query path:
SELECT ... FROM transcripts
WHERE profile_id = ? ORDER BY created_at DESC LIMIT ?
Previously SQLite had to choose between idx_transcripts_profile_id
(filter by profile, then in-memory sort by date) and idx_transcripts_created
(scan dates and filter on profile). Both work fine at hundreds of rows
and degrade past a few thousand.
`migration_v15_creates_profile_created_index` asserts (a) the index exists
and (b) `EXPLAIN QUERY PLAN` shows the planner picks it for the canonical
profile-scoped, date-ordered list query.
Test count assertions in `test_migrations_run_on_empty_db` and
`test_migrations_idempotent` bumped 14 → 15.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
The kon-mcp stdio server is documented as "read-only, no auth, local-only"
but until now opened the SQLite store via `kon_storage::init`, which returns
a writable pool and runs migrations. Read-only-ness was enforced only by the
exposed tool surface (list_transcripts, get_transcript, search_transcripts,
list_tasks); a future bug or a malformed dispatch could escape into a write
against the user's primary database.
Add `kon_storage::init_readonly` that opens with `SqliteConnectOptions
::read_only(true)` and `create_if_missing(false)`, no migrations. The
constraint is now structural — SQLite rejects writes at the connection
level regardless of which handler runs.
- New `init_readonly(path)` in crates/storage/src/database.rs.
- Re-exported from kon_storage.
- crates/mcp/src/main.rs switched over and updated startup banner.
- Two tests: writes fail on the read-only pool, reads succeed; opening a
non-existent DB returns an error instead of silently creating one.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
The Phase 9 bulk-delete path passed UUID strings to deleteFromHistory(index),
which expected an integer; JS coerced the string to NaN and splice(NaN, 1)
collapsed to splice(0, 1), so bulk-delete silently removed the first N visible
rows instead of the selected ones, then fired delete_transcript against the
wrong IDs.
Clear-all called saveHistory(), which was a no-op stub left over from the
same incomplete-refactor pattern that produced the manualTags persistence bug
fixed in 7eb52d9. The in-memory array was emptied, but SQLite still held
every transcript, so they reappeared on next loadHistory().
- Add deleteFromHistoryById(id) next to the index-keyed deleteFromHistory.
- bulkDelete now calls deleteFromHistoryById.
- clearAll now awaits an explicit per-id delete loop and surfaces a toast on
partial failure.
- Remove the saveHistory() stub and its sole caller's import.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
Captures the agent-runnable portion of Phase 10a ahead of Jake's
manual walkthrough and feedback-document pass:
- a11y baseline confirmed clean (svelte-check 0/0; consistent
aria-label + aria-hidden patterns across icon buttons; global
:focus-visible ring set in design tokens; prefers-reduced-motion
guards present where motion warrants them)
- WCAG 2.1 AA contrast tables for both themes computed from the
token list at design-system/colors_and_type.css. Nine pairs miss
AA-normal; light-theme warning misses AA-large too. Severity
ranked, suggested token shifts noted as starting points
- CI matrix state: check.yml runs on every push, build.yml has
never been end-to-end exercised - recommend manual workflow_
dispatch before tagging v0.1.0
- Clean-install test plan and the Phase 9d walkthrough checklist
consolidated for the testing session
Phase 9 export plumbing, LLM content tags (with migration v14 + storage
+ Tauri-command extension that picks up the latent manualTags
persistence bug as a side effect), and sparkline polish all on main.
SettingsPage deeper restructure and walkthrough-driven a11y sweeps
deferred to a follow-up polish session and Phase 10a QC respectively.
Roadmap Phase 9 entry updated with shipped + deferred notes. HANDOVER
captures the full session including the plan-correction discovery and
the Codex-blocked retries. Phase 8 handover preserved as the dated
archive HANDOVER-2026-04-24.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sparkline: friendlier aria-label ("3 completed today. 14 total over
the last 7 days." rather than a numeric list), per-bar <title>
tooltips with absolute date + count, 30 ms stagger entrance via
scaleY animation. Badge: 180 ms opacity + translateY entrance on
mount; conditional render means each new badge re-fires the
animation. Both animations respect prefers-reduced-motion.
The earlier draft tabindex=0 on the SVG was correctly flagged by
svelte-check as noninteractive_tabindex; SVG role="img" + aria-label
is sufficient for SR navigation without putting it in the keyboard
tab order.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 8 carryover backlog: the showMomentumSparkline toggle was
sitting under Rituals and visually claimed by the Launch-at-login
border-t subgroup. New top-level Tasks section hosts it, ready to
absorb future task-page settings (energy default, WIP limit, etc.).
The deeper Phase 9 SettingsPage restructure (search box + 7-group
progressive disclosure via the new SettingsGroup component) is
deferred to a follow-up polish session: the existing 2309-line file
uses a hand-rolled accordion that needs careful unwinding, and is
not in this session's scope. SettingsGroup component remains
available for that future pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reusable progressive-disclosure wrapper around the native <details>
element. Animated chevron, hover + focus-visible affordances, and
prefers-reduced-motion guard. Designed to host the six collapsed
groups in the Phase 9 SettingsPage restructure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-row Tag button calls extract_content_tags_cmd, persists via
saveTranscriptMeta. Dashed-italic chips render the AI tags distinct
from manual; clicking a chip promotes it into manualTags (the
LLM tag disappears, the manual one stays). Top toolbar gains "Tag all
untagged" for batch tagging across the corpus, 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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TranscriptEntry gains llmTags: string[]; TranscriptRow gains the
storage-shape llmTags: string. ContentTags type added. mapTranscriptRow
hydrates llmTags from the comma-joined column. saveTranscriptMeta
forwards llmTags through to update_transcript_meta_cmd, mirroring the
existing manualTags handling.
buildFrontmatter unions auto + manual + llm tags so exported markdown
surfaces every source in one tags: list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds llm_tags TEXT NOT NULL DEFAULT '' to the transcripts table via
new migration v14. SELECT statements + TranscriptRow + transcript_row_from
now carry the column. update_transcript_meta gains a sixth Option for
llm_tags following the existing COALESCE pattern; an
#[allow(too_many_arguments)] keeps clippy happy without inverting the
signature into a struct that would just shift the indirection.
The Tauri-side TranscriptDto + UpdateTranscriptMetaRequest + the
update_transcript_meta_cmd command pass llm_tags through unchanged.
Pre-existing manualTags persistence path now has a sibling for
llmTags ready for the frontend to call.
Phase 8 brittle test fix included: list_recent_completions_uses_local_day_boundary
was anchoring its "-2 days" UTC offset against the local-day spine,
which drifted across UTC midnight. Anchored to the local date directly
so it matches the spine regardless of clock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bridges LlmEngine::extract_content_tags to the frontend with the same
spawn_blocking + PowerAssertion guard the cleanup_text command uses.
Returns a ContentTags object serialised to camelCase JSON. Errors
surface as readable strings so the frontend toast shows actionable
text on the rare grammar-bypass path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Added as a method on LlmEngine alongside cleanup_text and
extract_tasks; same render_chat_prompt -> generate -> parse pattern.
Truncates the transcript to its trailing 2000 chars on a UTF-8 char
boundary, runs at temperature 0.0 with the CONTENT_TAGS_GRAMMAR GBNF,
and re-validates intent against INTENT_CLOSED_SET to catch the
unlikely grammar bypass case. max_tokens 96 is enough for the JSON
envelope. Smoke test gated on KON_LLM_TEST_MODEL like the existing
smoke.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ContentTags serde-serialisable. CONTENT_TAGS_SYSTEM is the system
message rendered at extraction time; INTENT_CLOSED_SET is the single
source of truth for the enum values the grammar restricts. Grammar is
strict: lowercase hyphen-joined topic 3+ chars (max enforced by
max_tokens at call site), intent from the closed set, JSON-only
output. Recursive topic-rest matches the existing GBNF style in this
file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slim leading checkbox on every row, tinted-row state when selected.
Bulk-action toolbar appears only when selection is non-empty: select
all (visible), clear, export selected (via exportTranscriptsToDir),
delete selected (single confirm). Esc clears selection. Cmd/Ctrl+A
selects all visible when focus is inside the list and not in a text
input. Stop-propagation on the checkbox keeps the click off the
row-expand toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the clipboard-only path with saveTranscriptAsMarkdown. User
picks the location via the OS save dialog; cancellation leaves no side
effect, no toast, no fallback. Toast on success names the file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Centralises the save-dialog plus write-file plumbing. suggestedFilename
slugs the title into "<slug>-<YYYY-MM-DD>.md". saveTranscriptAsMarkdown
opens the system save dialog and writes via write_text_file_cmd; on
cancel returns null with no toast or fallback. exportTranscriptsToDir
writes one .md per item to a chosen folder, in-batch collision suffix
" (2)" etc. Documents the deliberate non-check of pre-existing files
in the chosen directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin UTF-8 writer used by the new save-dialog path. Caller owns path
safety; the source path is always OS-dialog-provided. Two unit tests:
roundtrips a small UTF-8 string with non-ASCII chars and asserts a
nonexistent parent path returns an actionable error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-execution review against the actual codebase (Codex unavailable
this session) surfaced three mismatches: kon-llm uses LlmEngine with a
synchronous generate(prompt, config), AppState exposes llm_engine as a
direct Arc not behind RwLock, and llmTags persistence requires a real
SQLite migration plus Tauri command extension because saveHistory()
is a no-op stub today (which also means manualTags edits weren't
persisting). Plan now adds Task 8.5 for migration v14 + storage and
Tauri-command extension. Spec data-model section corrected to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sixteen tasks across four sub-phases (9a export plumbing, 9b LLM tags,
9b Settings restructure, 9d polish and a11y). TDD task structure for
the concrete items (save dialog, LLM extraction, types/persistence);
discovery-and-checklist structure for the polish items where an
a-priori test is not meaningful. Commit cadence matches Phase 8 (one
commit per task, feat/fix/polish prefix tags).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Downstream effect of the Task 4 commit (83bd338) adding
serde = "1" to crates/storage/Cargo.toml. Lockfile change is a
single line recording the new dependency on the pinned crates.io
version; no other crates affected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Roadmap: Phase 8 header now carries SHIPPED 2026/04/24 alongside the
REVISED 2026/04/23 marker. Added a shipped note summarising the
landing commits, architectural deltas, and verification state.
Pre-Phase-10 Cargo.lock decision updated to RESOLVED since Jake's
hardening pass (commit b333c62) committed the lockfile.
HANDOVER rewritten for today's state. Covers Phase 8 end-to-end,
counting semantics, three architectural notes worth carrying forward
(serde in kon-storage; no module-scope $derived export; tuple FromRow
pattern), full verification counts, the manual dogfood walkthrough
still owed to Jake when he next opens Corbie, and a Phase 9 polish
backlog surfaced during review (sparkline aria-label summary form,
sparkline toggle placement inside Rituals section).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default on. Controls only the sparkline; the "N today" badge is
unconditional. Copy kept in the zero-loss register: "Never counts
against you." Co-located with the Rituals section for Phase 8.
Can move to a dedicated section in Phase 9 polish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 8 completionStats store listens on these events to refresh the
daily count. Keeps the badge + sparkline accurate after un-tick / delete
paths, which don't currently fire kon:task-completed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Badge renders when today's count > 0. Sparkline renders when the
setting is enabled and any of the last 7 days has a completion.
Wrapped in a narrow aria-live region so increments announce without
re-reading the rest of the header.
Fix: converted todayCount from $derived module export to a getter
function (Svelte 5 derived_invalid_export constraint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tiny inline SVG. Seven bars, zero-days render as 1 px baseline stubs.
fill=currentColor so the parent's text colour (tertiary ink on the
Tasks header) drives it. Self-hides if all 7 days are zero.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owns the last-7-days completion series. Refreshes on task-completed /
step-completed / task-uncompleted / task-deleted window events and on
window focus (for day rollover). Derives todayCount from the newest
entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Phase 8 frontend types. New setting defaults to true (sparkline
on for everyone on upgrade) and persists via the existing settings
envelope; no migration needed because missing keys spread over the
defaults object on load.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin wrapper over kon_storage::list_recent_completions, parameterised
by day count. Serialises to camelCase JSON (day, count).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Returns a fixed-length, oldest-first series of daily completion counts
for the last N local-time days. Excludes cascade parents and
uncompleted rows. Empty days are explicit zeros, not missing entries,
so the Phase 8 sparkline can render a fixed 7 bars.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
uncomplete_task now clears auto_completed alongside done / done_at on
both the target row and the cascaded-parent reopen. Keeps the flag
accurate so a later re-completion via a different path is counted
correctly by the Phase 8 daily-count query.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the project's style preference for full stops over em/en dashes.
No functional change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
complete_subtask_and_check_parent now sets auto_completed = 1 on the
parent when it closes via the cascade. The subtask UPDATE itself
remains at the default 0, so explicit user taps still count toward
the Phase 8 daily total.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a flag on tasks to distinguish manual completions from the
cascade auto-completion performed by complete_subtask_and_check_parent.
Partial index on (done_at, auto_completed) supports the Phase 8
daily-count query without bloating the tasks index footprint.
Forward-only: pre-migration completed rows default to 0 (they count).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 bite-sized tasks with TDD on the Rust side (migrations +
cascade flag + list_recent_completions query + 4 storage tests) and
incremental frontend wiring (store, sparkline component, Tasks header
badge, event dispatches, settings toggle). Verification pass + roadmap
and HANDOVER update as final tasks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers today's completion count badge + 7-day momentum sparkline on the
Tasks header. Chose to exclude auto-cascade parents from the count via a
new auto_completed column (migration v13), to respect the zero-loss
framing. Sparkline is a new settings toggle, default on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Small if-then automation layer. Rules persist in SQLite; the runner
lives on the frontend and binds to the Phase 6 event bus so the rule
pipeline reuses the same delivery primitives (timer events, TTS,
Tasks navigation).
Storage:
- Migration v12 adds implementation_rules (id, enabled, trigger_kind,
trigger_value, actions_json, last_fired_key, created_at,
updated_at) with enabled+trigger_kind index for the runner's hot
path.
- CRUD helpers: insert / list / get / set-enabled / mark-fired /
delete, plus a round-trip test.
Commands (all main-window-guarded via ensure_main_window):
- list_implementation_rules
- create_implementation_rule — validates HH:MM, checks the target
task exists at save time for surface-task actions, caps
speak-line at 240 chars, pins v1 timers to 5 minutes.
- set_implementation_rule_enabled
- mark_implementation_rule_fired — main-thread idempotency shim so
the runner can atomically claim a fire.
- delete_implementation_rule
Runner (implementationIntentions.svelte.ts):
- Subscribes to kon:task-completed and kon:morning-triage-finished
(MorningTriageModal now emits on all three exit paths — empty,
skipped, picked — so skip counts as finishing).
- 30 s poll for time-of-day rules, plus an immediate check on startup
so a rule whose time has already passed today catches up once.
- Idempotency via last_fired_key composed as YYYY-MM-DD@HH:MM for
time rules; new time rules whose HH:MM has already passed today
are pre-seeded so they don't fire retroactively on save.
- Rules are paused when Nudges "Mute for now" is on — a hard mute
stops all rule delivery in addition to OS notifications.
- Stale-task safety: if a surface-task action's target has been
deleted, the runner opens Tasks and warns clearly rather than
pretending to surface something that's gone.
Editor (ImplementationRulesEditor.svelte):
- Lives in Settings under a new "If-then rules" accordion section.
- `If` picker: time of day (with time input), a task completes,
morning triage finishes.
- `Then` composer: optional surface (inbox / today / all tasks /
specific task), optional 5-min timer, optional speak-aloud line.
- Saved rules list with enable toggle + delete.
Rules table integration for Phase 10b rename sweep: add
implementation_rules to the kon.db → corbie.db migration shim when
that phase lands.
Gates: fmt, clippy -D warnings, cargo test 265/0, svelte-check
0/0, npm build green. Pre-existing Vite chunk warning on sounds.ts
is unrelated to Phase 7.
Frontend-owned nudge bus that consumes in-app signals Corbie already
produces, applies suppression, and fans out to OS notification + an
optional TTS read-aloud. OS-wide keyboard/window activity detection
stays deferred per the revised roadmap — the plan before rewrite
would have been brittle on Wayland, permission-heavy on macOS, and
low-quality everywhere.
Triggers (v1, all in-app signals):
- inactivity_with_active_timer — timer running, window blurred ≥ 90 s,
at least 60 s into the timer.
- pending_morning_triage — ritual enabled, past 10:00 local, last
shown ≠ today. Polls every 5 min while focused.
- micro_step_idle — micro-step decomposition created, no child step
or parent task completed within 15 min.
Suppression:
- Respects nudgesEnabled + nudgesMuted.
- No nudge while the app has focus (document.hasFocus).
- Hard cap 3 per rolling hour.
- Permission requested via @tauri-apps/plugin-notification on first
delivery; denial is silently respected.
Rust side:
- tauri-plugin-notification registered + ACL entries on the main-
window capability only (secondary windows can't fire nudges).
- commands::nudges::deliver_nudge — thin wrapper, security-guarded
via ensure_main_window, delegates to the plugin. No DB writes —
the roadmap's nudges-audit table is deferred until a concrete need
emerges.
Frontend glue:
- nudgeBus.svelte.ts — subscribes to window events, applies
suppression, calls deliver_nudge (+ tts_speak when speakAloud is
on).
- kon:task-completed now dispatched on complete_task_cmd success.
- kon:microstep-generated + kon:step-completed dispatched from
MicroSteps so the idle trigger can clear itself on any engagement.
- kon:focus-timer-cancelled added to focusTimer so the bus can reset
its inactivity state on cancel, not only on natural completion.
- nudgeBus started from +layout.svelte onMount, stopped on destroy.
Settings:
- New "Nudges" section with three toggles: Enable nudges, Mute for
now (separate so a hard mute doesn't lose preferences),
Speak nudges aloud (reuses Phase 4 TTS).
- All default OFF. No first-run prompt — nudges are a Settings-found
feature rather than a walkthrough step.
Out of scope per the revised Phase 6 spec: OS-wide keyboard/window
hooks, biometric signals, custom trigger editor (Phase 7), notification
sound (platform variance too high for Layer-1 — revisit in Phase 9
polish with a bundled .wav).
Gates: fmt clean, clippy -D warnings clean, cargo test 262/0 (4
existing + 262 current), npm run check 0/0, npm run build green.
Three opt-in rituals, all default OFF. Research-anchored (Barkley's
point-of-performance, Sweller cognitive-load theory, Newport shutdown
ritual, Gollwitzer implementation intentions, Thaler/Sunstein nudge
with informed consent for the ADHD audience).
Morning triage: modal gated on ritualsMorning toggle, configurable
trigger time (default 08:00 to respect ADHD sleep inertia rather than
the spec's 06:00), "pick up to three for today" with a gentle swap
message on the fourth attempt. Skip sets last-shown-today so it never
re-prompts the same calendar day. last-shown persists via kon_storage.
Evening wind-down: dedicated page, user-triggered only (tray menu +
Settings button). Mechanical closure + physical reset + intentional
cue — the whole Newport template. Open loops are read-only reflection;
Tasks page owns transactions. Copy is additive throughout: "you
finished X today", never "you didn't finish Y".
Autostart: tauri-plugin-autostart registered (LaunchAgent on macOS,
.desktop on Linux, registry Run on Windows). No bespoke Rust commands
— frontend calls the plugin's invoke-handlers directly. Toggle in
Settings is one-way (click → OS call → state update) to avoid the UI
lying during the round-trip. First-run presents a forced-choice prompt
for all three options, with "skip all" escape hatches per step.
Copy audit against RSD literature: no "overdue", "failed", or
day-to-day comparison framing anywhere in ritual surfaces.
Post-v0.1 ideas captured in the roadmap: calendar integration
(read-only ICS as interim, cloud sync parked) and right-click-to-task
(in-app simple, system-wide a separate phase).
Platform-dispatched TTS (spd-say + espeak-ng fallback on Linux, say on
macOS, PowerShell System.Speech on Windows) with a shared SpeakerButton
component. Tap to speak, tap again to stop; only one button speaks at
a time so two surfaces don't talk over each other. Text always travels
via argv (or a PowerShell here-string delivered through -EncodedCommand
on Windows) so user content never enters a shell string.
Mount points: DictationPage transcript footer, transcript viewer header,
per-step in MicroSteps. Settings gains a "Read aloud" accordion with
voice picker (lazy-loaded from the OS synth), rate slider 0.5-2.0x,
and a British-English test utterance.
Rust tests cover rate mapping, NaN handling, and Windows here-string
terminator safety. No pause/resume, no SSML, no cloud voices — that
stays out of scope per the Layer-1 roadmap.
Codex post-implementation review flagged one MAJOR: the energy segmented
control declared `role="radiogroup"` / `role="radio"` but only wired
`onclick`. No arrow-key navigation, no Home/End, no roving tabindex.
Keyboard users got four independent tab stops while assistive tech was
told it was a single radio group — a broken ARIA contract.
Fix (W3C APG Radio Group pattern):
- Extract the options list as `energyOptions` so the render loop and
the keyboard handler share one source of truth.
- `energyRadioKeydown` handles ArrowLeft/Right/Up/Down (cycle wraps),
Home (first), End (last).
- Roving tabindex: the currently-checked button gets `tabindex=0`,
the rest get `tabindex=-1`, matching the APG recipe. Focus moves
with selection.
- The radiogroup container gets `tabindex="-1"` to satisfy the
svelte-check a11y rule without creating its own tab stop.
All green: 251 tests, clippy -D warnings, fmt, svelte-check 0/0, build.
Closes Phase 3 of the 2026-04-23 feature-complete roadmap. Incorporates
the Codex plan-review fixes from this session: profile-free index, tri-
state update command, and de-prioritise-not-hide semantics.
Storage (kon-storage):
- Migration v11 adds `energy TEXT` to `tasks` with a CHECK constraint on
`high | medium | brain_dead | NULL`. Index `(energy, created_at DESC)`
— deliberately not per-profile because the tasks table carries no
profile_id column yet (tracked as a separate gap in HANDOVER).
- `TaskRow.energy: Option<String>` plus `task_row_from` read.
- `insert_task` signature grows by one optional arg (`energy`). Allowed
`too_many_arguments` with a rationale comment — the positional shape
matches the column order and flipping to a params struct would have
rippled through every caller for cosmetic benefit only.
- New `set_task_energy(pool, id, Option<&str>) -> TaskRow`. Lives as its
own function because `update_task` uses COALESCE to let `None` mean
"preserve" — which would make clearing the tag impossible.
- Two new tests: round-trip including explicit NULL clear, and CHECK
constraint rejection of unknown values.
- Tests updated for the v10 → v11 version bump.
Tauri (src-tauri):
- `TaskDto.energy`. `CreateTaskRequest.energy` (optional). Inline
validation against the allowed set before hitting the DB, so frontend
bugs surface as friendly errors instead of CHECK-constraint failures.
- New `set_task_energy_cmd` command mirroring the storage tri-state API.
Frontend (svelte):
- `EnergyLevel` type added to `types/app.ts`. `TaskDto`, `TaskEntry`, and
`TaskDraft` grow an `energy` field.
- `SettingsState.currentEnergy` (persisted) + `matchMyEnergy` (persisted
toggle). Defaults: null + false — no surface change until user opts in.
- `setTaskEnergy(id, EnergyLevel | null)` action on the task store.
Calls the dedicated Tauri command, updates local state, broadcasts to
sibling windows.
- `EnergyChip.svelte` — new component. Cycles unset → High → Medium →
Brain-Dead → unset on click. Colour tokens: accent / warning /
text-tertiary (deliberately not danger-red for Brain-Dead — the brief
is explicit that this state must not feel pathologised).
- Chip rendered on every task row in TasksPage and every row in
WipTaskList. Hidden-until-hover when energy is unset so untagged rows
stay calm; always visible once tagged because the colour is the signal.
- Tasks page header gains a "I feel" segmented control and a
"Match my energy" toggle. When both are active, matching tasks sort
to the top — unset tasks are treated as Medium-equivalent. Nothing is
ever hidden; this is a de-prioritisation, not a filter.
Deferred / out of scope:
- LLM-driven surfacing (brief says "The AI surfaces...") — deterministic
client-side sort is v1; LLM layer is a later phase.
- tasks.profile_id + per-profile energy sort — separate migration.
All green: cargo build + 251 tests + clippy -D warnings (0 warnings)
+ fmt + svelte-check (0/0) + npm run build.
Independent review surfaced three majors and one nit. All actioned.
MAJOR 1 — profile scoping:
`decompose_and_store` and `extract_tasks_from_transcript_cmd` now
accept an optional `profile_id` (wired from `profilesStore.activeProfileId`
in MicroSteps.svelte and DictationPage.svelte), and thread it into the
feedback-retrieval query so per-profile decomposition styles do not
leak into each other. `record_feedback` gets the same treatment.
MAJOR 2 — prompt-budget regression on long inputs:
New `trim_to_budget` helper + `FEW_SHOT_CHAR_BUDGET = 2000` char cap
in `src-tauri/src/commands/tasks.rs`. Retrieval still pulls up to 5
rows but they are char-counted and truncated against the budget
before being sent to the LLM. Char cost matches the `Input: ...\n
Good output: ...` render path so the budget maps cleanly to ~570
Qwen3 tokens, well inside the 8192-context reserve after the 512-
or 768-token response allocation. Oldest-first drop order (iteration
stops at cost exceeded) preserves the most recent correction which
is the one carrying the user's live preference.
MAJOR 3 — inline edit stale-rollback race:
`saveEdit` in MicroSteps.svelte now stamps a monotonic per-step
`saveToken`. Each edit bumps the token; on failure the rollback
only fires if `saveToken[step.id] === myToken`, so a slow-failing
first save can no longer overwrite a faster successful second save.
NIT — retrieval ordering stability:
`list_feedback_examples` ORDER BY now `created_at DESC, id DESC`.
SQLite timestamp precision is one second; without the secondary
key, bursty feedback within the same second would select
non-deterministically.
Also: malformed `context_json` now warns via eprintln! rather than
disappearing silently — Codex minor.
All green: cargo build + 249 tests + clippy -D warnings + fmt
+ svelte-check (0/0) + npm run build.
Closes the human-in-the-loop gap from docs/brief/feature-set.md and
Phase 2 of the 2026-04-23 feature-complete roadmap.
Storage (kon-storage):
- Migration v10 adds the `feedback` table: (target_type, target_id,
rating, original_text, corrected_text, context_json, profile_id,
created_at) with CHECK constraints on target_type and rating, plus
indexes on (target_type, rating, created_at DESC) for prompt-time
retrieval and (profile_id, target_type, created_at DESC) for
per-profile scoping.
- New public API: `FeedbackTargetType`, `RecordFeedbackParams`,
`FeedbackRow`, `record_feedback`, `list_feedback_examples`.
- Tests updated — the RB-02 rollback regression now discovers the
real max version at runtime instead of hard-coding v10 for its
poison migration.
LLM (kon-llm):
- `prompts::FeedbackExample` — local shape for few-shot exemplars so
kon-llm stays independent of kon-storage.
- `prompts::build_conditioned_system_prompt` — appends a "here is
the style this user prefers" block to the base system prompt
when examples are available; returns the base prompt unchanged
when empty, so new users and early sessions see generic output.
- `LlmEngine::decompose_task_with_feedback` and
`LlmEngine::extract_tasks_with_feedback` thread examples through
to the builder. The old one-arg variants are preserved and now
call through with an empty slice.
- 4 unit tests covering empty, empty-input-skip, correction-wins,
and thumbs-up-only fallback.
Tauri (src-tauri):
- New commands::feedback module: `record_feedback`,
`list_feedback_examples_cmd`.
- `decompose_and_store` and `extract_tasks_from_transcript_cmd`
now fetch the last 5 positive/neutral feedback rows for their
target type and pass them through to the LLM, wiring the
learning loop end-to-end.
- Shared `to_llm_examples` helper parses the `context_json.input`
field (where the recorder stashes the parent task text / transcript
chunk) back into the exemplar shape.
Frontend (MicroSteps.svelte):
- Thumbs-up and thumbs-down buttons on every micro-step row.
Hover-revealed; the vote recolours the icon; clicking again
clears the local highlight (the row itself stays in the audit
trail).
- Pencil icon + double-click to edit step text. Save flows through
update_task_cmd for persistence and records a correction feedback
row with (original_text, corrected_text) — the highest-value
training signal.
- Parent task text is captured in context_json.input at record time
so the prompt builder can reconstruct the (input, preferred-output)
pair on subsequent decompositions.
- Feedback capture is best-effort — a record_feedback failure never
interrupts the primary action.
What's deferred to a later phase:
- Thumbs + corrections on extracted tasks (same pipeline, different
surface — probably TasksPage after the AI-extraction path)
- Thumbs on transcript cleanup output
- Semantic retrieval over the feedback corpus (once there is enough
data to justify embedding infrastructure; the storage shape is
already ready for it)
Jake's feedback on Phase 1: make the timer pinnable / always-on-top,
combined with the existing Now-list pop-out. Two changes:
1. Mount <FocusTimer /> in src/routes/float/+layout@.svelte so the
running countdown stays visible in the always-on-top float window
alongside the WIP task list. No content change to the float page
itself — the timer is a global overlay.
2. Add a pop-out icon to the main-window focus timer that opens the
existing /float route via window.open. One click → timer + Now
list pinned on top without touching main window focus. Hidden
inside the float window itself (detected via URL) so you cannot
recursively pop out.
Result matches the Todo float-out UX the user already knows:
click ExternalLink, you get a small always-on-top window with
tasks + a live countdown ring in the top-right.
Closes the Core MVP gap in docs/brief/feature-set.md ("visual time
representation") and wires the dangling kon:start-timer emit that
MicroSteps.svelte has been firing into the void since the stub was
written. Implements phase 1 of the 2026-04-23 feature-complete
roadmap.
New:
- src/lib/stores/focusTimer.svelte.ts — singleton timer store with
localStorage persistence so a timer started in Dictation survives
page nav, window close, and reopen. Gentle WebAudio chime at
completion (no bundled asset). 250 ms tick. Completion flash for
3 s before auto-clear.
- src/lib/components/FocusTimer.svelte — floating top-right overlay
with SVG progress ring (shrinking colour: accent -> warning in
the final 15% -> success on completion). Cancel + "+1 min" on
hover. Renders nothing when idle.
Wired in +layout.svelte next to ToastViewport.
Two triggers now in the app:
- MicroSteps row 2-min button (pre-existing emit, previously no
listener)
- WipTaskList row 5-min button (new; the brief's "just-start"
from the Now column)
Respects prefers-reduced-motion via the existing
[data-reduce-motion] attribute.
Out of scope, carried to later phases: rhythmic voice anchoring
(Phase 6), custom-duration picker, multi-timer UI, native OS
notification (deferred to Phase 6 with the full nudge pipeline).
Auto-applied cargo clippy --fix across 11 files — needless return,
unnecessary cast, map_or simplification, repeat().take() → repeat_n(),
iter().any() → contains(), manual char comparison, lifetime elision,
push_str single-char, reference immediately dereferenced.
Also fixed three lints on file_storage.rs manually: two doc-list-item
overindentations, plus the same needless-return. Baseline main was
not clippy-clean with -D warnings before; after this pass one
needless_range_loop warning remains (live.rs:1089) that clippy's
suggested rewrite would make less readable — left for a dedicated
refactor session.
Build + workspace tests remain green (245 passing, 0 failing, 1
ignored).
The accumulator task was fire-and-forget — `tokio::spawn` without
retaining the JoinHandle. `stop_native_capture` sent a stop signal,
slept 50ms, and returned; the worker could still be running its
final flush and appending to `all_samples` when the next
`start_native_capture` cleared it. Rapid start→stop→start could
leak tail samples from one session into another.
Replace `NativeCaptureState.stop_tx` with `worker:
AsyncMutex<Option<CaptureWorker>>`, where CaptureWorker owns both
the stop sender and the spawned task's JoinHandle. New helper
`stop_worker(worker)` sends stop, drops the sender, and `.await`s
the join. Both the prior-worker tear-down in `start_native_capture`
and `stop_native_capture` itself go through the helper, so the
worker is always fully terminated before any downstream read or
next-session cleanup.
AsyncMutex (not std::sync::Mutex) because the stop path awaits
while holding the lock. Also drops the 50ms sleep from
stop_native_capture — the join is an exact barrier.
Two regression tests:
- stop_worker_awaits_full_termination_no_writes_after_join:
synthetic worker with an atomic counter and a flush marker.
After stop_worker the flush must have run and no further
writes may appear.
- stop_worker_is_idempotent_on_a_worker_that_has_already_exited:
tasks that stop themselves must still join cleanly.
A full cpal-backed start→stop→start integration test is not
feasible in Linux CI without an audio device. The component tests
cover the invariant the real flow depends on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
get_runtime_capabilities was returning `accelerators = ["cpu",
"vulkan"]` and `whisper.supports_gpu = true` regardless of build
config or runtime state. On a macOS build it falsely advertised
"vulkan" (the backend actually resolves via MoltenVK as Metal); on a
whisper-disabled build it claimed GPU support for an engine that
hadn't been linked.
Added `compose_accelerators(whisper_enabled, loader_available, target)`
— a pure helper that always emits "cpu" first and appends the
platform-appropriate GPU name only when whisper is compiled in AND the
Vulkan loader resolves. `supported_accelerators()` wraps it with the
live `cfg!(feature = "whisper")`, loader probe, and target OS.
`get_runtime_capabilities` now calls `supported_accelerators()` and
sets `whisper.supports_gpu = cfg!(feature = "whisper")`. Parakeet
stays CPU-only.
Five tests in `commands::models::tests` cover the permutation matrix:
whisper on/off, loader present/missing, macOS vs other. Both feature
configurations (`--features whisper` and `--no-default-features`)
build and pass tests.
Macos Metal-loader resolution on real hardware stays on the
ship-gate checklist — the detection logic is verifiable from Linux
but runtime behaviour is not.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every multi-statement migration and its matching schema_version insert
now execute on the same sqlx Transaction. A failure anywhere — a bad
statement, the version insert, or the commit itself — rolls the
database back to its previous state, so the next startup replays the
migration against a clean schema rather than a half-mutated one.
Extracted run_migrations_slice(pool, migrations) as the single apply
path. run_migrations delegates to it with MIGRATIONS; the test helper
run_migrations_up_to now filters MIGRATIONS by target and delegates to
the same code, eliminating the duplicated loop that previously lived
in the test module.
Regression test multi_statement_migration_rolls_back_on_failure
injects a poisoned v9 migration (valid CREATE followed by a bogus
function call) and asserts neither the partial schema change nor the
schema_version row persists after the failure.
SQLite DDL participates in transactions, so this is sufficient. Any
future migration that needs an implicitly-committing statement
(VACUUM / REINDEX / ATTACH — none today) must be its own
non-transactional migration; that's a reviewer responsibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
decode_audio_file's packet loop was `Err(_) => break`, so any non-EOF
read error during playback dropped out silently with whatever samples
had accumulated. Per-packet decode errors were tallied and skipped,
contributing to the same outcome. A corrupt or truncated input
therefore came back as `Ok(partial_samples)` — no way for callers to
distinguish a clean decode from a compromised one.
Every SymphoniaError other than the explicit EOF
(`IoError(UnexpectedEof)`) now maps to `AudioDecodeFailed`. Decoder
errors bubble via `?` rather than being counted. `ResetRequired`
promotes to an error rather than a silent break.
Extracted an internal `decode_media_stream(mss, hint)` so tests can
inject a custom `MediaSource`. Added `FlakyCursor` — a seekable cursor
that returns a synthetic I/O error after N bytes — and a regression
test that confirms mid-stream read failure surfaces as `Err` instead of
returning partial audio. Happy-path and missing-file tests added for
coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
try_attach_device was rejecting any device that did not report KEY_A or
KEY_R — a leftover heuristic from the whisper-overlay seed. A user whose
binding was anything else (Ctrl+Shift+D is a common default) would see
no hotkey events from that device even though it supports the key.
Replace the hard-coded check with device_supports_combo(supported,
combo), a pure helper that reads the configured trigger key code from
the HotkeyCombo snapshot. Snapshot is taken from hotkey_rx.borrow()
before opening the device; an unconfigured or shutting-down listener
short-circuits to a non-attach.
Four regression tests in linux::tests cover: supported+D → attach,
unsupported → reject, no reported keys → reject, and the explicit
non-A/non-R case that demonstrates the bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every issue under docs/issues/ links to this file as its Source. It was
created for 592b894 but not staged, leaving dangling links in the
release-blocker tracker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the 12 items from docs/code-review-2026-04-22.md that
must land before v0.1 ships. One markdown file per issue with:
severity, path:line, problem description, acceptance criteria,
fix scope, and dependency graph.
Split by severity:
- 3 CRITICAL: live-session race, migration atomicity, transcript-
profile FK
- 9 MAJOR: monolith refactor, channel-fatality, capture worker
join, runtime capabilities, macOS App Nap, decoder error prop,
LLM prompt preflight, keystore thread-safety, hotkey device
filter
README.md indexes them with a fix-order dependency graph and a
fish-shell script for bulk-converting to GitHub issues once `gh`
CLI is installed and authed. Deferred step by user decision —
markdown tracker is authoritative until then.
MINOR from the batch review of 6e9ed99: SystemTime::now() alone
cannot guarantee uniqueness under tight loops — two calls in the
same clock tick can return identical secs + nanos on some OS
timing resolutions. The filename reduction from "every second"
to "every nanosecond" addresses the flagged bug but leaves a
theoretical gap.
Adds a process-lifetime AtomicU64 counter, zero-padded to 4 digits,
as the third filename component. New shape:
kon-<secs>-<nanos_in_sec>-<counter>.wav
e.g. kon-1776828000-123456789-0000.wav
Across process restarts the counter resets to 0, but the wall-clock
secs/nanos have advanced — no cross-launch collisions possible.
Within a single process, the counter guarantees uniqueness regardless
of clock behaviour.
Test strengthened from ">=32 of 64 unique" (probabilistic) to
"1024 of 1024 unique" (absolute).
Regression surfaced by the batch review: commit 8400128 switched
list_transcripts from unwrap_or_default to map_err(-32602). This
correctly errors on malformed payloads but also rejected the common
case where a client omits the 'arguments' field entirely — which
arrives as Value::Null, and serde_json::from_value does not
deserialise Null into a struct.
Short-circuits the Null case to Args::default() before attempting
deserialisation. Genuine shape mismatches ("limit": "twenty")
still return -32602 as the previous test asserts.
New regression test: tools/call with list_transcripts and no
arguments key must return a successful response.
2026-04-22 review MINORs and NITs:
- crates/core/src/providers.rs: delete entire module. SpeechToText /
TextProcessor / ProviderRegistry were forward-looking traits that
never got wired — the Transcriber trait in kon-transcription
(A.2 #13) has since superseded SpeechToText, and the Registry
pattern was redundant against LocalEngine. Keeping them as dead
public surface signalled future direction that is no longer
accurate.
- crates/core/src/types.rs: delete TranscriptMetadata. Forward-
looking struct with an unfulfilled TODO; storage has evolved
independently through v7/v8 migrations without adopting it.
- crates/ai-formatting/src/llm_client.rs: remove #[allow(dead_code)]
from CLEANUP_PROMPT and format_dictionary_suffix. Both are
actively called; the suppressions would hide future genuine
dead-code warnings in this regression-sensitive prompt file.
- src-tauri/src/commands/live.rs: remove #[allow(dead_code)] from
LiveStatusMessage. Every variant (Warning, Overload, Error,
Finished) is constructed in the module today.
- README.md: update kon-transcription row from "SpeechToText
trait" to "Transcriber trait" and mention the new streaming/
module.
Workspace test gate green (225 lib tests across all crates).
MAJOR from the 2026-04-22 review (crates/mcp/src/lib.rs:188-195):
the handler called serde_json::from_value(args).unwrap_or_default(),
so a request like { "limit": "twenty" } silently became the default
limit of 20. Every other tool handler in this file map_errs to
-32602 Invalid arguments; this one was the outlier.
Switches to the same map_err pattern. Empty params still
deserialise cleanly to Args::default (via #[serde(default)] on the
Option<i64> field), so callers that send no args are unaffected —
only genuinely malformed shapes now error.
Regression test: tools/call with list_transcripts and a
string-typed limit must return code -32602 with an "Invalid
arguments" message.
MAJOR from the 2026-04-22 review (crates/mcp/src/main.rs:26-30): the
stdio transport logged malformed JSON lines to stderr and continued
without sending any JSON-RPC response. Clients saw silence instead of
the -32700 Parse Error they could key off. handle_message has a
parse-error branch for shape mismatch, but it never ran for bytes
that failed to parse as JSON at all.
Exposes a new public helper kon_mcp::parse_error_response(detail)
that mirrors the existing internal error_response pattern, filling
id with null per JSON-RPC 2.0 §5.1 (parse error, no id recoverable).
main.rs now writes that response out before continuing the read
loop.
Regression test on the helper asserts: jsonrpc "2.0", id null,
code -32700, message starts with "Parse error" and includes the
underlying serde detail.
MINOR from the 2026-04-22 review (build.rs:47-64): the guard used
strip_prefix("connect-src") to find the directive, which would also
match hypothetical future directives like connect-src-elem and
validate the wrong allow-list.
Switches to split_once(char::is_whitespace) on each directive and
requires the first token to equal "connect-src" exactly. Robust
against prefix collisions with any future CSP3 directive
additions.
MAJOR from the 2026-04-22 review (database.rs:389-449):
complete_subtask_and_check_parent auto-completes a parent task when
the last child completes, but uncomplete_task only flipped the
requested row — reopening a child left the parent wrongly marked
done, breaking the "parent done iff every child done" invariant.
Wraps uncomplete_task in a transaction and, after flipping the
subtask, looks up its parent_task_id. If present, resets the
parent to done=0 as well. Scoped to "done=1" on the parent update
so an already-open parent is untouched.
Two regression tests:
- uncomplete_subtask_reopens_auto_completed_parent: the direct
mirror of the existing subtask_crud_roundtrip completion flow.
- uncomplete_top_level_task_does_not_touch_siblings: ensures the
parent-reopen branch is a no-op for tasks with no parent, and
siblings without a parent relationship are unaffected.
MAJOR from the 2026-04-22 review (model_manager.rs:161-262): reqwest
does not return Err on 4xx/5xx by default. The resume branch
validated 206/200 and errored on anything else, but the non-resume
branch skipped the status check entirely — a 404 or 500 body was
streamed into .part and atomically renamed over the destination as
if it were the model file. For models without a sha256 declared,
this failure mode was silent and catastrophic (the engine would
crash loading an HTML error page as GGML on next launch).
Adds an is_success() check in the non-resume branch: any non-2xx
returns KonError::DownloadFailed with the HTTP status in the
message, and (importantly) we return before File::create so no
.part file is left behind.
Test: spawn_500_server that responds 500 to any request; a fresh
(no .part, no sha256) download must Err with "HTTP 500" and leave
neither .part nor dest on disk.
MAJOR from the 2026-04-22 review (wav.rs:135-145): read_wav used
filter_map(|s| s.ok()) on both integer and float sample iterators, so
any per-sample decode error (truncated payload, corrupted format
descriptor after a partial write) was silently discarded. Callers
received Ok with a short samples vec, losing audio without any signal
to investigate.
Switches to collect::<Result<Vec<f32>>>() with a map that converts
hound's per-sample errors into KonError::AudioDecodeFailed. First
error aborts the read rather than returning a partial vector.
Test fabricates the regression by writing a valid WAV and chopping
the last 10 bytes; the previous filter_map would have returned Ok
with a shortened vec, the new code returns Err.
MAJOR from the 2026-04-22 review (audio.rs:236-257): filenames were
derived from SystemTime::now().as_secs(), so two recordings started
within the same second resolved to the same path — possible overwrite
or merge.
Extracts the filename generation into a private helper and appends
the sub-second nanosecond component. Format is now
`kon-<secs>-<nanos_in_sec>.wav`, e.g. `kon-1776828000-123456789.wav`,
which stays human-readable, sortable by timestamp, and effectively
uncollidable under any realistic live-capture pattern.
Two tests cover the regression:
- recording_filenames_are_unique_across_rapid_calls: 64 tight-loop
calls must produce at least 32 unique names.
- recording_filename_has_expected_shape: prefix/suffix plus the
zero-padded 9-digit nanos component.
MAJOR from the 2026-04-22 review (paste.rs:181-217): unlike
paste_text, the replace flow did not snapshot the user's clipboard
before writing the raw transcript. A successful revert left the raw
transcript on the clipboard and destroyed whatever the user had
copied before invoking it — inconsistent with paste_text's Handy-#921
contract (brief item #3).
Extracts the snapshot+restore pattern into two helpers shared
between paste_text and paste_text_replacing:
- snapshot_clipboard_text() -> Option<String>
- schedule_clipboard_restore(prior, transcript)
Both paste_text and paste_text_replacing now run the same
capture-before-stomp, restore-after-fire sequence. The restore-
guard (only restore if clipboard still holds the transcript we set)
is shared too, so a user who copies something new within the 300 ms
window is still respected.
DRY: removes ~15 lines of inline clipboard bookkeeping from
paste_text while making the replace flow identical.
CRITICAL from the 2026-04-22 code review: RmsVadChunker::flush() was
calling consume_frame() on a zero-padded final frame via `let _ = ...`,
discarding any VadChunk the call emitted. If the padded frame triggered
end-of-utterance (silent tail + padding zeros push past
silence_close_samples) or max_chunk_samples (buffered speech + padding
push past the cap), the emitted chunk was lost; the outer state check
then either returned None or an empty closing chunk.
Changes the VadChunker trait flush signature from Option<VadChunk> to
Vec<VadChunk> so both the mid-flush emission (from consume_frame) and
the closing emission (from emit_active_chunk_and_close) can be
surfaced. Updates RmsVadChunker::flush to collect from both sites
and skip a zero-length closing emit when the hit_max continue variant
already cleared active_chunk.
Two regression tests land alongside:
- flush_preserves_hit_max_chunk_from_padded_final_frame: tight
max_chunk, sub-frame speech tail; pre-fix dropped the chunk, post-fix
emitted samples cover the full active-speech region.
- flush_preserves_end_of_utterance_chunk_from_padded_final_frame:
silent tail near silence_close; padded zero frame closes the
utterance inside consume_frame; pre-fix returned None.
No production callers yet — the VadChunker wiring in live.rs is a
deferred item from A.3. API change is clean within the repo.
Review feedback (MINOR): char::is_whitespace returns false for
zero-width format codepoints (U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ,
U+2060 WORD JOINER, U+FEFF ZWNBSP / BOM). The original normalise
pass let them through to the LLM where they waste tokens without
contributing any natural-language content.
Makes the decision explicit: these chars STRIP entirely rather than
collapse to a space. Collapsing would silently insert a word break
where the source had none ("hello<FEFF>world" → "hello world"
would merge two words into a space-separated pair that the original
author did not intend). Stripping preserves the original token
boundaries and drops the invisible noise.
Three new tests:
- zero_width_format_chars_strip_entirely — exhaustive coverage of
all five handled codepoints.
- zero_width_chars_do_not_break_adjacent_whitespace_collapsing —
"hello <FEFF> world" still collapses to "hello world" (the
strip does not leave behind an artefact that breaks the whitespace
collapse pass).
- leading_bom_is_stripped — a BOM at segment start, the common
artefact pattern when Whisper consumes an encoded file.
New crates/ai-formatting/src/to_plain_text.rs module with one public
function: to_plain_text(&[Segment]) -> String.
Rules the function enforces:
- each segment's text is whitespace-normalised (any run of unicode
whitespace collapses to a single ASCII space, so tabs, newlines,
and NBSPs never reach the LLM),
- empty and whitespace-only segments are dropped,
- remaining segments are joined with a single ASCII space,
- the joined string is normalised again (so a segment ending in a
space followed by one starting in a space does not produce a double
space) and trimmed end-to-end.
pipeline.rs's inline join is replaced with this call. Whisper's
timestamp fields (Segment.start / .end) are carried separately and
never reach the LLM by construction — the "timestamps stripped"
half of brief item #29's acceptance falls out of using Segment.text
alone. The work the module actually adds is whitespace discipline
and the tested boundary (empty input, empty-only input, NBSPs,
pathological whitespace runs, idempotence, double-space at join
boundaries).
Source: Scriberr PR #288 — feeding raw Whisper JSON (with timestamps
and per-segment structure) degraded cleanup quality; plain-text
input raised it back.
Review feedback (MINOR): the original <= 0.0 clamp caught negatives
and zero but not non-finite inputs. Rust's saturating float-to-int
cast turns f64::INFINITY into u64::MAX, which would park the capture
buffer origin beyond any reachable sample index and trim the whole
buffer forever if a future end_secs source ever produces infinity
(clock glitch, overflow upstream, corrupted timestamp in a pass).
Adds is_finite() check. NaN, +infinity, -infinity, and zero all
return 0, which downstream trim_buffer_to_commit_point treats as
no-op. Test covers all three non-finite cases.
Review feedback (CRITICAL): when a chunk hit max_chunk_samples during
continuous speech, emit_active_chunk reset state to Idle. The next
1-2 loud frames of post-split continued speech went into onset_buffer
and were silently cleared if silence arrived before the 3-frame onset
threshold — 50-100ms of user audio lost at every max-chunk boundary
in long-continuous-speech scenarios.
Splits emit_active_chunk into two variants:
- emit_active_chunk_and_close: the existing behaviour. Used for
end-of-utterance closes and end-of-session flush. Truncates trailing
silence, resets state to Idle.
- emit_active_chunk_continue: mid-utterance split on max_chunk. Stays
in State::InSpeech, clears active_chunk for continued accumulation,
advances active_chunk_start by the emitted length so the next
chunk's start_sample is contiguous with this one's end. No
silence-trim (by definition still in speech — end-of-utterance
takes priority).
Adds max_chunk_split_preserves_audio_contiguity test: feeds 17 frames
of continuous speech into a chunker with a 4-frame cap, asserts
(a) chunk[i+1].start_sample == chunk[i].start_sample + chunk[i].samples.len()
across every pair, and (b) the final emitted region reaches the end
of the fed speech with no sample loss.
Review feedback (CRITICAL): LocalAgreement::push could panic with an
index OOB when a later pass arrived shorter than committed_count.
Concrete case: commit [a, b], next pass arrives [a] — lcp_len=1,
new_committed=max(1, 2)=2, then latest[2..] panicked because
latest.len()==1.
A Whisper re-transcription of an overlapping window can legitimately
collapse repeated segments, or the user can stop mid-utterance after
some tokens were already committed, both of which produce this
shape. The committed_count invariant still holds (non-shrinkage) —
it is the slicing that was unsafe.
Clamps every latest[..] slice against latest.len() before indexing.
committed_count stays at new_committed even when the pass is shorter:
non-shrinkage is relative to what we have already emitted, not to
the current pass length. newly_committed and tentative both return
empty when the shorter pass has nothing past the committed prefix.
Adds two regression tests:
- shorter_pass_after_commit_does_not_panic (commit 2, push 1)
- empty_pass_after_commit_does_not_panic (commit 1, push empty)
New streaming::buffer_trim module with two pure helpers:
- sample_index_for_seconds(end_secs, sample_rate) -> u64: converts
LocalAgreement::last_committed_end_secs() into an absolute sample
index. Defensive against negative end_secs (treats as 0) so a future
clock-skewed timestamp cannot wrap to a huge u64.
- trim_buffer_to_commit_point(buffer, buffer_start_sample,
commit_sample_index) -> new_buffer_start_sample: drains the prefix
of the capture buffer that falls below the commit point and returns
the new absolute-index origin.
Edge cases covered by tests:
- commit before buffer start → no drain
- commit equal to buffer start → no drain
- commit inside buffer → drain prefix, advance origin
- commit at buffer end → drain all, origin moves forward
- commit past buffer end → drain all, origin parks at commit (rare
edge after a committer reset)
- sample_index_for_seconds rounds nearest, negatives clamp to 0
- integration with LocalAgreement::last_committed_end_secs
trim_bounds_buffer_over_long_session is the acceptance fixture for
the ufal #120/#102 failure mode: 100 cycles of 16_000 captured samples
with a 200-sample tentative tail per cycle, and the buffer stays below
2× the tentative envelope instead of growing to 1.6M samples.
Integration into src-tauri/src/commands/live.rs deferred to the
dogfood session that wires VadChunker and LocalAgreement end-to-end —
the trim is a one-line replacement at the maybe_dispatch_chunk drain
site once the committer is feeding it commit points.
New streaming::commit_policy module implementing ufal's
LocalAgreement-n pattern: tokens emitted by the streaming ASR
pipeline stay tentative until N consecutive passes produce the same
prefix, at which point the agreed prefix commits.
Types:
- Token: text + absolute start/end seconds. PartialEq is text-only so
identical words from overlapping Whisper windows compare equal even
when their timestamps drift.
- CommitDecision { newly_committed, tentative }: the partition fed
back to the live-session worker after each pass.
- CommitPolicy enum with LocalAgreement { n }. Default is n=2 (ufal).
- LocalAgreement: stateful committer with push/flush/reset and a
last_committed_end_secs accessor. Brief item #25 uses the latter
to compute the sample-index drain target for aggressive buffer trim.
Invariants exercised by tests:
- first pass is all tentative (need 2 passes to commit under n=2)
- two matching passes commit their common prefix
- divergent second pass commits nothing
- extending agreement commits only the newly-agreed tokens
- tentative tail tracks latest pass only (no stale guesses)
- committed prefix never shrinks, even if later passes contradict
- flush emits any tentative-but-not-committed tail at session end
- flush on empty history is a no-op
- reset clears all commit state
- n=3 requires three matching passes before anything commits
- CommitPolicy::default() is LocalAgreement { n: 2 }
Integration into src-tauri/src/commands/live.rs deferred — the
tentative/committed split needs the B-side 'tentative: bool' field on
LiveResultMessage.segments (workstream-B #24 UI contract) and
validation against real streaming captures before going live.
New crates/transcription/src/streaming/ module with:
- VadChunker trait: Send-bound, object-safe, push/flush/reset/
next_sample_index. Same surface a future Silero backend will
present, so live.rs wiring does not change when Silero drops in.
- VadChunk type: (start_sample: u64, samples: Vec<f32>) for
commit-policy sample-offset bookkeeping in #24.
- RmsVadChunker: fallback backend the plan permits while the
ort 2.0.0-rc.10 vs rc.12 ecosystem conflict blocks silero-vad-rust
/ voice_activity_detector. Tuned to match the existing
evaluate_speech_gate behaviour (enter 0.003, exit 0.0014, 3-frame
onset, 500 ms silence close, 2 s max chunk).
Key behavioural properties, each backed by a test:
- pure silence emits nothing
- samples between exit and enter thresholds never trigger onset
- a single loud frame does not start a chunk (sustained speech only)
- sustained speech followed by silence emits exactly one chunk
- hysteresis: a dip between enter and exit does not split a chunk
- max_chunk_samples caps continuous speech (Whisper never fed > 2 s)
- flush surfaces in-flight speech
- flush on an idle chunker emits nothing
- reset restores a clean state
- emitted chunk start_sample includes the onset buffer (Whisper sees
the speech attack, not post-onset audio)
Open items tracked as follow-ups:
1. Silero backend via direct ort rc.12 bridge (Handy-style). Blocked
on either ecosystem ort alignment or dedicated bridge session.
2. Integration into src-tauri/src/commands/live.rs. Deferred so
threshold tuning can be validated against real microphone
captures rather than synthetic constant-signal fixtures.
Review feedback: reported_audio_path was cached at writer-open time
and survived a mid-session writer drop. If an append error cleared
wav_writer (or hound's Drop ran instead of finalize), the Summary
still reported the path — risking StopLiveTranscriptionResponse
pointing at a file whose header did not reflect its data chunk.
Moves the decision to end-of-session. wav_writer.take() is inspected:
- writer present + finalize Ok → report the path,
- writer present + finalize Err → None, emit a Warning,
- writer absent (mid-session drop, or never opened) → None.
StopLiveTranscriptionResponse.audio_path now means "the recording is
known-good" rather than "a recording was attempted". Users can still
recover partial files via filesystem if needed; the warning toasts
already emitted by append_resampled_audio on write failure surface
that path implicitly.
Review feedback: src-tauri/src/commands/models.rs was still naming
load_whisper unconditionally, so a --no-default-features workspace
build (kon-transcription without the whisper feature) would have
compiled the transcription crate cleanly but failed in the kon crate
as soon as it tried to resolve the load_whisper symbol.
Adds a matching [features] section to src-tauri/Cargo.toml:
- default = ["whisper"]
- whisper = ["kon-transcription/whisper"]
and declares the kon-transcription dep with default-features = false so
the feature actually propagates rather than being forced-on by the
child crate's default. Cfg-gates the load_whisper import in models.rs
and adds a companion match arm that returns a runtime error when the
feature is off, keeping the API shape intact.
Verified with both cargo build -p kon and cargo build -p kon
--no-default-features.
The Vec<f32> in-memory accumulator on run_live_session had three
failure modes: (a) a crash during transcription took the recording
with it, (b) RAM grew linearly with session length, (c) OOM killed
the capture thread silently.
New kon_audio::WavWriter wraps hound::WavWriter<BufWriter<File>> with
an append-friendly API and a 500 ms-granularity header flush. On any
abort after a flush the on-disk file is a valid, playable WAV. Unit
test (brief item #19 acceptance) simulates the abort with
std::mem::forget and asserts the pre-flush samples are recoverable.
Live capture now:
- resolves the destination path at start_live_transcription_session
time via a new resolve_recording_path helper extracted from
persist_audio_samples,
- opens a WavWriter before any samples arrive, sample rate taken from
LocalEngine::capabilities() (#13 wiring) with 16 kHz fallback,
- feeds the resampler output through WavWriter::append inside
append_resampled_audio — drops the writer with a user-visible
warning if a write fails mid-session,
- calls flush() at stop (after resampler tail), finalise() on clean
exit, and drops-to-last-flushed state on abort.
LiveSessionSummary.audio_samples → audio_path: the path is already
written by the time stop_live_transcription_session runs; no
post-session write step remains for live capture.
persist_audio_samples is kept for the offline save_audio command.
New crates/transcription/src/transcriber.rs defines a Transcriber
trait (Send supertrait for spawn_blocking travel) with
TranscriberCapabilities (sample_rate, channels, supports_initial_prompt).
TranscriberCapabilities.sample_rate is load-bearing for the upcoming
progressive WAV writer (#19).
Concrete impls:
- SpeechModelAdapter wraps Box<dyn transcribe_rs::SpeechModel + Send>
for Parakeet (and any future transcribe-rs-backed engine).
- WhisperRsBackend moves its transcribe_sync body into the impl,
widening the signature from &self to &mut self so per-call
WhisperState can be created cleanly through the trait object.
LocalEngine now holds Box<dyn Transcriber + Send>; dispatch in
transcribe_sync collapses from a match to a direct call. Adds
LocalEngine::capabilities() for the WAV-writer.
Cargo feature flag "whisper" (default on) makes whisper-rs, num_cpus,
and the whole whisper_rs_backend module optional. cargo check
--no-default-features -p kon-transcription now builds without pulling
whisper-rs-sys — the escape hatch brief item #6 / #13 called for on
Windows / non-AVX2 / cloud-only builds. load_whisper is cfg-gated
behind the same feature.
src-tauri/src/commands/models.rs load_model_from_disk returns
Box<dyn Transcriber + Send> instead of SpeechBackend; caller chain
(ensure_model_loaded, prewarm_default_model) is unchanged.
transcriber_trait_is_object_safe test lands alongside the trait as a
compile-time witness against future Self-returning / generic-method
additions.
Review feedback: the previous test would pass even if the Range-header
logic in download_file were deleted entirely, because File::create
truncates the stale .part regardless of which branch set
actually_resuming to false.
Tightens spawn_no_range_server to return HTTP 400 when the request
carries no Range header, and only return 200 + full body when Range is
present. A regression that stops sending Range now surfaces as a
download failure (empty body from 400, bytes != body assertion)
instead of silently passing through the truncation path.
Review feedback: the original guard substring-searched the whole file
after the first "csp" token, which (a) false-passes if any unrelated
JSON value elsewhere in the config happens to contain a localhost URL
and (b) false-fails if the CSP is ever re-serialised with escaped
forward slashes.
Switches to serde_json + a /app/security/csp pointer lookup, then
splits the CSP on ';', finds the connect-src directive, tokenises its
allow-list on whitespace, and requires an exact match for both
http://127.0.0.1:* and ws://127.0.0.1:*. The error now also includes
the current connect-src value so a developer who breaks it can see
exactly what needs restoring.
The downloader already handles servers that return 200 to a Range
request by falling through to a truncating File::create on the .part
path, discarding stale partial bytes. That branch had no dedicated
fixture test — the SHA mismatch and Range-honouring resume cases were
covered but the mirror / proxy that strips Range support was not.
Adds spawn_no_range_server (always 200, full body regardless of Range
header) and download_file_restarts_when_server_ignores_range. Writes 12
bytes of stale content to .part, kicks off a download, asserts the
final file matches the fresh body exactly (not stale-bytes-prefixed)
and the .part file is cleaned up.
Brings the transcription downloader to test-coverage parity with
crates/llm/src/model_manager.rs per brief item #8 ("test coverage
parity" acceptance).
Pins the connect-src CSP entries for http://127.0.0.1:* and
ws://127.0.0.1:* at build time. If a future edit to tauri.conf.json
strips the local-LLM permit, the kon crate fails to build rather than
shipping a binary whose webview fetch() silently 404s with an opaque
scope error (Vibe #438 / #487).
Closes item #2 of docs/whisper-ecosystem/brief.md — CSP widening itself
landed in an earlier commit; this is the regression-proofing the plan
calls for.
resolveVolume previously clamped any value >=1 to full blast. If a
future settings change ever leaks a 0–100 scale through (instead of
0–1), the user gets jump-scared at max volume. Treat any v>1 as a
scale-drift bug and play at DEFAULT_VOLUME instead.
Also reject NaN / Infinity explicitly rather than relying on the
<=0 branch catching them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runs Mondays 06:00 UTC (plus workflow_dispatch) so any freshly
published advisory surfaces as its own failing run rather than
slipping into an unrelated PR's check.yml noise. npm audit is
gated to --audit-level=high to skip the low/moderate chatter that
doesn't warrant a bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
svelte-check catches type/template errors Vite's build skips; cargo
test --workspace --lib runs our pure-unit suites (prompt contract,
hallucination filter, preset parsing) without GPU or runtime deps.
Test step is Linux-only so the Windows/macOS legs stay focused on
platform compile coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new Settings → AI knobs that compose cleanly with what already
shipped (aiTier, LLM model, translator prompt framing).
**B.1 #15 — Named cleanup presets.** LlmPromptPreset enum
(Default / Email / Notes / Code) appends a short context hint onto
the CLEANUP_PROMPT just before generation. Presets shape tone and
structure ("email paragraph", "bulleted meeting notes", "preserve
technical terms") without licensing the content-editing the
translator-not-editor framing forbids. cleanup_transcript_text_cmd
now takes `preset: Option<String>` which runs through the new
LlmPromptPreset::parse (normalises aliases like "meeting-notes",
collapses unknown values to Default).
**A.1 #28 — Sequential-GPU guard.** New LocalEngine::unload drops
the backend + model_id so a subsequent load actually reclaims VRAM.
load_llm_model, load_model, and load_parakeet_model Tauri commands
grow an optional `concurrent: bool` argument. When concurrent is
Some(false), loading LLM first unloads whisper+parakeet, and vice
versa — prevents VRAM OOM on tight-VRAM setups. Default is the
previous parallel behaviour so nothing changes for multi-GB cards.
Transcribe-in-progress paths (transcribe_pcm, transcribe_file, live)
pass None, so mid-dictation model loads don't accidentally tear
down the LLM.
Settings UI (AI section):
- Cleanup preset segmented button + descriptive copy for each option.
- GPU concurrency segmented button with explicit trade-off text
("faster transitions vs fits in tight VRAM").
Frontend wiring:
- settings.llmPromptPreset flows from DictationPage's
cleanupTranscriptIfEnabled into the Tauri command.
- settings.aiGpuConcurrency flows from both DictationPage (auto-load
on record) and SettingsPage (manual load/unload buttons) as
`concurrent: "parallel" === true` to the load commands.
Tests: three new preset cases in crates/ai-formatting/src/llm_client.rs
(parse aliases, suffix non-empty for non-default, default suffix
empty). All 139 existing lib tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The brief's pain point is opaque load failures: llama-cpp-2's errors
bubble up as raw C++ strings ("cudaMalloc failed: out of memory",
"invalid gguf magic"). A user seeing that has no path to recovery.
New backend command test_llm_model runs a staged diagnostic:
1. Model not downloaded → `not-downloaded` + download hint.
2. File size ≤90% of expected → `incomplete` (stalled download)
+ re-download hint. Matters because llama-cpp-2 can segfault
on truncated GGUF rather than returning cleanly.
3. Requested model already loaded → `ready`, no side effects.
4. Otherwise attempt a real load. On failure, classify_llm_load_error
maps the raw string to one of:
- load-failed-vram (OOM / cudaMalloc / allocation)
- load-failed-corrupt (GGUF magic / unsupported format)
- load-failed-permission (permission denied / access denied)
- load-failed-other (catch-all)
Each category has a prewritten actionable hint pointing at the
specific Settings surface (tier picker, re-download, file perms).
classify_llm_load_error is pure-string and unit-tested — 8 cases
covering the main categories plus edge cases (OOM alias, Windows
"Access is denied", unknown errors). Ordered narrow-to-broad so
overlap doesn't misclassify.
Settings UI gets a "Test" button in the AI section's action row,
visible whenever the model is downloaded (both downloaded-idle and
loaded states). Shows inline hint below the status line when the
test surfaces one. Refreshes both local and global LLM status after
the test since a successful test implicitly loads the model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hands-off feedback for recording lifecycle: a short C5 pitch at
record start, a falling G4 at record stop, and a staggered C5–E5–G5
major third when the finalise flow completes. Synthesised at runtime
via the Web Audio API (OscillatorNode + linear-ramped GainNode
envelope) rather than shipping WAV assets — keeps the binary size
flat and lets us tweak timbre without touching bundled files.
Off by default. Settings → Output exposes the toggle with a volume
slider (0–100%, default 15%) and a "Test" button that plays the
completion cue so the user can confirm loudness without recording.
Hooked at three call sites in DictationPage:
- playStartCue after page.recording = true in startRecording
- playStopCue at the top of stopRecording
- playCompleteCue just before `saved = true` at the end of
finaliseTranscription's transcript-present branch
All three no-op when settings.soundCues is false. The Web Audio
context is lazily constructed on first cue (most browsers suspend
it until a user gesture — Tauri's webview inherits that). If the
AudioContext can't be built we silently drop the cue rather than
throwing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ufal/whisper_streaming #161 documents the classic Whisper streaming
failure: on ambiguous audio the model falls into a prompt loop,
cascading a single token for 10+ words ("I I I I I I I I I I I…").
The chunk-boundary duplicate detector in live.rs doesn't catch
this — the repeat is within a single chunk, and the text is
technically novel so FTS is happy to keep it.
Fold the detection into is_hallucination as a third pass (after
HALLUCINATION_MARKERS substring-match and HALLUCINATION_TRAIL_PHRASES
exact-match). has_consecutive_repetition walks the token stream
(whitespace-split, lowercased) and returns true when any run of
≥REPETITION_RUN_THRESHOLD (4) identical tokens is found.
Threshold chosen deliberately: three consecutive matches appear in
normal speech ("no no no, that's wrong"), four almost never does.
Tests pin both sides — "I I I I I" detected, "no no no" allowed,
alternating patterns ("I am I am I am I am") allowed regardless of
length.
Phrase-level repetition ("thank you thank you thank you thank you")
is a documented companion failure mode but needs a sliding n-gram
matcher — deferred with a code comment flagging it.
No caller changes: post_process_segments already drops
is_hallucination hits when anti_hallucination is enabled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Whisper was trained on subtitle corpora, so silence and room tone
trigger caption-style artefacts that the previous three-marker
blocklist ("[blank_audio]", "[music]", "[silence]") didn't catch:
"Thanks for watching!", "Please subscribe.", "ご視聴ありがとうござ
いました", "♪♪♪", etc. Documented in WhisperLive #185 / #246 and
ufal/whisper_streaming #121 as the top streaming-transcript-quality
issue after chunk-boundary repeats.
HALLUCINATION_MARKERS widens from 3 to 16 entries: all common
bracketed non-speech tags (applause / laughter / inaudible /
background noise / sounds), parens variants, and musical notation
(♪ / ♫). Still contains-match so the marker triggers even when
Whisper wraps it in other noise.
HALLUCINATION_TRAIL_PHRASES (renamed from AUTO_THANKS_PHRASES) jumps
from 4 to ~30 entries: YouTube sign-offs, subtitle-credit leakage,
and the two most common non-English variants (Japanese "thanks for
watching" + MBC Korean news sign-off). Stays exact-match so
legitimate dialogue containing "thanks" or "subscribe" mid-sentence
never gets dropped — a new regression test pins that invariant.
The <15-char length gate on trail phrases is removed; some of the
new entries (e.g. "please subscribe to our channel.") are longer.
Exact-match against a known list is safety enough.
No caller changes: post_process_segments already drops segments for
which is_hallucination returns true when anti_hallucination is on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The LLM runtime has been quiet since it shipped in Phase 3 — users
had no surface-level signal that cleanup was loaded, warming, or
actively generating. Settings has verbose status text internally,
but a dictation-flow user never opens Settings during a run.
New: a shared $state store drives a small chip in the sidebar that
reflects the true LLM state in ≤500 ms of any transition (brief
item #31 acceptance). Five states:
off → hidden (user has aiTier === "off")
warming → model download or first load in flight; amber pulse
ready → loaded + idle; green dot
generating → cleanup_transcript_text_cmd or
extract_tasks_from_transcript_cmd in flight; accent
pulse with Sparkles icon
error → last operation failed; red dot with AlertTriangle
The store exposes three calls: refreshLlmStatus(aiTier) (polls the
backend), markGenerating(detail) / markGenerationDone(success).
DictationPage wraps its cleanup + extract calls in mark-generating
pairs. SettingsPage's LLM load / unload / delete / download paths
also refresh the global store so Settings-initiated transitions
surface in the sidebar immediately. The chip collapses to a
dot-only compact form when the sidebar is collapsed.
No backend changes — everything wires onto the existing
`get_llm_status` Tauri command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Kon's ideology rule: raw Whisper output is the source of truth; LLM
cleanup is additive, never destructive. The preview overlay already
tracks both rawText and finalText across the listening → live →
cleanup → final phases — but until now the user had no one-click path
from final to raw if cleanup changed their meaning.
Frontend: a context-aware "Use raw" / "Copy raw" button appears in
the preview overlay's final phase, only when rawText and finalText
actually differ (Raw format mode or LLM-off leaves the button hidden).
Two behaviours depending on how the transcript reached the target:
- settings.autoPaste = true → invoke paste_text_replacing, which
sends the platform's undo keystroke to the focused app,
waits UNDO_PASTE_GAP_MS (60 ms) for the compositor / app to
process it, then pastes the raw transcript. The preview hides
itself beforehand so the keystroke doesn't race focus
(existing Wayland-hardening path).
- settings.autoPaste = false → nothing was pasted in the first
place, so just overwrite the clipboard with raw. User's own
paste yields raw.
Backend: new paste_text_replacing Tauri command plus a mirror of the
paste-backend matrix for undo (wtype -M ctrl z / xdotool key ctrl+z /
ydotool keycodes 29:1 44:1 44:0 29:0 / osascript cmd+z / SendKeys '^z').
Reuses the pick_linux_backend_order Wayland-vs-X11 preference.
Registered in the Tauri command handler.
Acceptance per the brief: "after paste, Ctrl+Z within 5 s replaces
LLM output with raw transcript" — satisfied via the 4 s auto-hide
window on the preview's final phase. The click extends auto-hide so
the user actually sees the confirmation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
prewarm_default_model loaded the model and returned. That moves the
model into RAM, but whisper.cpp still allocates its context window +
fills GPU shader caches on the first inference call — producing the
~4–5 s cold-start latency documented in ufal/whisper_streaming #96
and #135 that feels like "Kon dropped my first sentence."
Extend the pre-warm task: after engine.load, feed one second of
silence (16000 zero samples at 16 kHz) through transcribe_sync with
default options. Silence returns empty segments; the *work* is the
context allocation, which now happens at app boot rather than on the
user's first hotkey press.
Net: the user's first real dictation should complete within ~1.5× the
steady-state RTF they'll see on subsequent runs, satisfying the A.1
#23 acceptance criterion. No new public API; all inside the existing
spawn_blocking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous prompt led with "You are a transcript cleanup assistant"
and listed cleanup rules. That framing quietly licenses the LLM to
treat cleanup as content editing — rephrasing for clarity, summarising
long sentences, "improving" phrasing. That's precisely the failure
mode OpenWhispr / Scriberr / Whispering users complain about ("the
LLM changed my meaning").
New framing lifts Whispering's published baseline: "translator from
spoken to written form — not an editor trying to improve the content."
Adds an explicit rule: do NOT improve, summarise, expand, or rephrase;
faithful written-form translation only, never content editing.
Both load-bearing concerns are now regression-tested — the existing
prompt-injection hardening assertions stay, and a new test pins the
translator framing + explicit no-editing rule against drift during
future refactors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the repo had no root README. Pitch + architecture + crate-level
breakdown + routes + runtime stack + roadmap all lived in dated HANDOVER
docs + docs/brief/ + memory. This pulls the canonical overview into the
single place a new contributor / investor / cursory browser will actually
land first.
Sections: design principles, what Kon does today (shipped capabilities),
architecture (3-layer: Rust workspace → Tauri commands → Svelte UI),
crate-by-crate responsibilities, Tauri command surface, frontend shape,
runtime stack with pinned versions, platform support matrix, build + dev
setup, testing, pointers into docs/, roadmap, contributing notes, licence
placeholder, contact.
All claims are grounded in current code state (136 tests, 9 crates, 18
Tauri command modules, Tauri 2.10.3, whisper-rs 0.16, llama-cpp-2
0.1.144, sqlx 0.8.6). No marketing puff; preserves the ideology-firm
stance from memory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds colors_and_type.css token system, fonts (Lexend, Instrument Serif Italic,
JetBrains Mono, Atkinson Hyperlegible Next, OpenDyslexic), SVG assets (wordmark,
waveform mark, grain), HTML preview spec cards, UI kit, and SKILL.md reference
under src/design-system/. Foundation for applying the new Kon visual identity.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the bulk import ran new Set(...) on raw trimmed strings
before lowercasing, so 'ACME' and 'acme' both survived the first
dedupe pass. Neither existed in the store, so both got added —
defeating the commit message's claim that pasting the same block
twice with different casing is a no-op.
Collapse case variants at the initial dedupe step using a lowercase
seen-set, keeping the first occurrence's casing as written.
Co-authored-by: jars <jakejars@users.noreply.github.com>
The preview window was missing from the default capability's window
list. Its frontend calls core:window:allow-hide (getCurrentWindow().hide()
on dismiss / auto-hide) plus invoke('copy_to_clipboard') and a handful
of event listeners — none of those would have been permitted at runtime
because the capability never matched this window's label.
Add 'transcription-preview' alongside the other secondary windows so
the preview actually has access to the permissions it already relies on.
Regenerate gen/schemas/capabilities.json to match.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Extends commands/paste.rs::paste_text with a pre-keystroke check:
if GetForegroundWindow (Windows) / xdotool getactivewindow (Linux
X11) / 'tell System Events ...' (macOS) reports a focused app class
matching KNOWN_TERMINAL_CLASSES, skip the synthesised Ctrl+V and
return an outcome with copied=true, pasted=false, and a user-facing
message ('Terminal detected (kitty) — use Ctrl+Shift+V to insert').
Matches Handy #692: Kitty/Alacritty/Windows Terminal/Codex CLI all
double-insert the transcript when a PTY sees both a synthesised
Ctrl+V and the terminal's own paste hotkey. The terminal list is
ordered most-specific-first so 'windowsterminal' wins over the
generic 'terminal' needle.
Adds classify_terminal() as a pure helper + seven unit tests. The
platform probe (detect_focused_window_class) isolates the fragile
shell-out so the classification rule is test-covered without
needing a real desktop. Wayland doesn't expose a reliable
focused-window API to unprivileged clients, so it conservatively
returns None and the normal paste path runs — consistent with
Kon's Wayland-Pipewire lane.
Prior-clipboard restore (#3) is intentionally skipped when terminal
mode takes over: the user's path to insert the transcript is their
own paste, which needs the transcript on the clipboard.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Adds src/lib/utils/hotkeyValidity.ts with validateHotkey(combo, os)
and wires it into HotkeyRecorder.svelte. Rules:
- X11/Wayland/Linux: reject single-key combos unless the trigger
is F13..F24 (the conventional global-shortcut escape hatch).
- Windows: reject combos whose only modifier is a right-hand
variant (RCtrl/RAlt) — matches Handy #966's failure mode where
RegisterHotKey silently ignores them.
- macOS: reject Fn-only combos and bare-key combos (non-function
keys).
- unknown OS: pass through — better to ship a flawed combo than
reject one we can't validate.
When validation fails, the recorder leaves the previous known-good
hotkey intact and surfaces an inline warning-coloured sentence
explaining why, with actionable copy ("add a modifier", "use the
left-hand equivalent", etc.). The save button disappears because
settings.globalHotkey is never written to — no state drift.
Matches Handy #917 / #1019 / #966 / #956, brief item #5.
Co-authored-by: jars <jakejars@users.noreply.github.com>
A rapid double-tap of the global hotkey, evdev autorepeat, or a
sticky-key compositor quirk (KDE's 'slow keys') can all deliver
the same press twice within ~100ms. Without a guard, the recording
toggles into and out of the same frame and the capture is lost.
Gates the evdev 'kon:hotkey-pressed' forwarder in +layout.svelte
behind a 120ms debounce (Date.now()-based; no timers, so no tail
latency for a legitimate single press). The debounce is intentionally
shorter than a deliberate double-press cadence but longer than any
autorepeat interval we've seen in the wild.
The audio-stream-warming half of brief item #4 (Handy #1143) lives
in Workstream A's Phase A.3 warm-up WAV; this covers the UX side.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Extends commands/paste.rs so that when auto-paste fires we:
1. capture the user's existing clipboard text BEFORE overwriting
it with the transcript (via arboard::Clipboard::get_text — a
non-text clipboard returns None and the restore step is
skipped, which keeps images / files safe),
2. after the paste keystroke lands, sleep 300 ms,
3. restore the snapshot only if the clipboard still holds the
transcript we wrote — respects any Cmd+C the user did in the
300 ms window.
Moves the decision to a pure should_restore(current, transcript)
helper with four unit tests. The existing paste-matrix regression
tests are unchanged.
Matches Handy #921 (workstream-B brief item #3). Coexists with the
Wayland preview-hide dance already in this file, and doesn't run
when the paste keystroke fails (transcript stays on the clipboard
for the user's own paste).
Co-authored-by: jars <jakejars@users.noreply.github.com>
Adds src/lib/utils/settingsMigrations.ts exposing
loadSettingsWithMigration() / saveSettingsWithVersion() around a
{version, data} envelope in localStorage["kon_settings"]. The
migration chain is indexed by destination version so adding a v2
is one MIGRATIONS[2] = (prev) => next entry away from working.
Legacy bare-object settings blobs are treated as v0 and folded into
v1 identically to before — no user-facing reset — but an unreadable
blob now surfaces a single Settings reset toast instead of silently
dropping data.
Covers Handy #602 ('settings reset on update') and unblocks the
other B.2/B.3 SettingsState additions listed in
docs/whisper-ecosystem/workstream-B.md: every subsequent field
lands behind a MIGRATIONS step, so older Kon builds stay readable.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Sequences the 13 B-scope items from docs/whisper-ecosystem/brief.md
into three phases (pre-emptive UX, feature pinches, LLM layer) with
stop-for-review boundaries between each.
Enumerates the Settings sections touched per item (net: +2 toggles,
+2 sub-cards, nothing invisible becomes visible), the new
SettingsState fields with defaults, the schema migration bump
(version 1 -> 2), and the explicit Workstream A dependencies +
stubbed fallbacks for each (#14 list_gpus, #30 streaming cleanup,
#31 llm-state-change event).
Co-authored-by: jars <jakejars@users.noreply.github.com>
Two follow-ups to the previous CI deps commit:
1. Linux: libclang-dev installs libclang.so under
/usr/lib/llvm-*/lib and /usr/lib/x86_64-linux-gnu, but
bindgen-0.72.1 does not probe the llvm-versioned directory by
default. Resolve the newest /usr/lib/llvm-*/lib candidate
dynamically and export LIBCLANG_PATH so bindgen finds it without
any clang-sys guesswork. Also install glslang-tools + spirv-tools
so find_package(Vulkan COMPONENTS glslc) succeeds against
ggml-vulkan's cmake step.
2. macOS: llama-cpp-sys-2 sets GGML_VULKAN=ON unconditionally when
the vulkan feature is enabled; cmake's find_package(Vulkan) then
fails on a vanilla macOS-latest runner because there is no
Vulkan SDK shipped by default. The LunarG macOS SDK ships as a
non-scriptable interactive installer, so we compose MoltenVK +
Vulkan loader + headers + shaderc via Homebrew formulae instead.
Export VULKAN_SDK=$(brew --prefix) and CMAKE_PREFIX_PATH=same so
both the build.rs branch and cmake's FindVulkan resolve headers /
libs / glslc from /opt/homebrew.
Applied symmetrically to check.yml and build.yml. Windows config is
unchanged.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Two issues with the previous #12 approach, both caught by CI:
1. tauri-build rejects the '_comment' json field as unknown when
parsing tauri.windows.conf.json:
unknown field `_comment`, expected one of `$schema`,
`product-name`, `productName`, ...
The schema is strict, so the doc-comment has to live elsewhere.
2. tauri-build's bundle.resources list is resolved at build-script
(cargo check) time, not at 'tauri build' time. With the DLLs
intentionally gitignored for licensing reasons (see the dir's
README), every cargo check run on Windows would fail.
Fix: delete tauri.windows.conf.json entirely. The intent of #12 —
'runtime falls back to CPU when Vulkan is absent' — is already
live in src-tauri/src/commands/models.rs::detect_active_compute_device,
unchanged.
Rewrite resources/windows/README.md to document a cargo tauri build
--resource ... invocation for the release engineer. That's the only
invocation that needs the DLLs present; everyone else (including
CI's cargo check) doesn't go near them.
This matches how Kon already handles CI/release split elsewhere
(macOS code-sign certs, Windows code-sign certs, etc. all stay out
of tauri.conf.json for the same reason).
Co-authored-by: jars <jakejars@users.noreply.github.com>
Both whisper-rs-sys and llama-cpp-sys-2 run bindgen at build time,
which requires libclang.so/dylib/dll resolvable from the loader.
None of the three GitHub runners ship this out of the box today:
- ubuntu-22.04: bindgen-0.72.1 panics with 'couldn't find any
valid shared libraries matching libclang.so*'
- macos-latest: same panic against libclang.dylib
- windows-latest: choco already installed llvm here, but libclang
was on an unset LIBCLANG_PATH, so bindgen still couldn't find it
And llama-cpp-sys-2's vulkan feature (wired on by whisper-rs' vulkan
feature → whisper-rs-sys + its own shared ggml build) hard-panics on
Windows when VULKAN_SDK is unset, and needs libvulkan.so linkable on
Linux.
Changes, applied symmetrically to check.yml and build.yml:
- Linux: add libclang-dev, clang, libvulkan-dev to apt-get install.
- macOS: brew install llvm, set LIBCLANG_PATH to brew --prefix
llvm /lib so bindgen can load libclang.dylib.
- Windows: choco install vulkan-sdk, set VULKAN_SDK to the
newest-version directory under C:\VulkanSDK (resolved
dynamically so a minor-version bump doesn't hardcode-break
anything), set LIBCLANG_PATH to the llvm bin dir.
Unblocks the per-push cargo check job on main, phase4-systems-f7d0,
and phase4-ux-f7d0; also unblocks the release build. The Windows
Vulkan SDK install is the new long pole (~2 min on a cold runner)
but is needed unconditionally while the vulkan feature is on in
crates/transcription/Cargo.toml.
Co-authored-by: jars <jakejars@users.noreply.github.com>
src/lib/utils/textMeasure.ts imports @chenglou/pretext but the
package was only present in the local node_modules — npm reported it
as 'extraneous'. npm ci in CI refuses to install anything that isn't
declared, so vite build fell over at the Rollup resolve step:
[vite]: Rollup failed to resolve import '@chenglou/pretext' from
'src/lib/utils/textMeasure.ts'.
Pins to the version that was already installed locally (0.0.5) and
regenerates package-lock.json. npm run build now completes cleanly
through the SvelteKit / Vite / adapter-static pipeline.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Adds src-tauri/tauri.windows.conf.json with a bundle.resources list
for vulkan-1.dll, libssl-3-x64.dll, and libcrypto-3-x64.dll shipped
side-by-side with kon.exe, plus a src-tauri/resources/windows/README.md
explaining how release engineers populate the directory (licensing
constraints keep it manual rather than scripted).
The runtime fallback is already live from commit A.1 #1: if the
Vulkan loader is missing after launch, emit_runtime_warnings() fires
a runtime-warning event (kind: vulkan-loader-missing) and
get_runtime_capabilities() reports activeComputeDevice=cpu with a
reason. The app starts and transcribes on CPU — degraded but never
broken — so the acceptance criterion 'launches cleanly on a VM with
no GPU' holds by construction.
Matches Whispering #840/#829 and Buzz #1459 pain patterns.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Adds src-tauri/src/commands/power.rs exposing a PowerAssertion RAII
guard that macOS uses to pin NSProcessInfo.beginActivityWithOptions
around long-running work. Wired into:
- run_live_session (entire live-dictation lifetime)
- cleanup_transcript_text_cmd's spawn_blocking body (LLM run)
Non-macOS targets get a no-op guard so callers don't have to #cfg
the call sites. The actual Objective-C bridge to NSProcessInfo is
stubbed (begin_activity returns Err so the guard logs a warning
instead of silently pretending); the stub doesn't regress recording
or LLM behaviour on macOS — it just means App Nap is not yet
suppressed, which matches today's behaviour. Full objc2 integration
is a follow-up that can introduce objc2 cleanly in its own commit.
Matches Whispering #549/#559 pain-pattern; acceptance text ("10
minute background recording completes unattended") is satisfied
once the bridge is finished, and nothing regresses today.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Ports the kon-llm model_manager resume pattern the rest of the way
into kon-transcription::model_manager:
- download() now validates an existing complete file against its
sha256 before skipping; a hash mismatch removes the file and
re-fetches, instead of serving a corrupt file to whisper.cpp.
- download_file() now distinguishes 206 Partial Content, 200 OK
(resume silently ignored by server), and other statuses, rather
than treating any non-206 as 'just use it as a fresh start'.
200-on-Range is handled by discarding the partial and starting
over cleanly.
- New tests: download_file_resumes_from_partial_and_verifies_sha
(TcpListener fixture, same shape as kon-llm's), and
download_file_fails_on_sha_mismatch_and_cleans_part_file.
- sha256_of_file helper + unit test for the existing-file guard.
Dev-deps: tempfile + tokio(net/io-util/macros). Total workspace
lib-test count: 116 → 123.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Extends get_runtime_capabilities() with three new fields:
- activeComputeDevice: {kind, label, reason} — "GPU (Vulkan)" on
the happy path, "CPU (fallback)" with a reason when the Vulkan
loader is absent at runtime. libloading::Library::new probes
libvulkan.so.1 / vulkan-1.dll / libMoltenVK.dylib per target OS.
- cpuFeatures: { avx2, avx512f, fma, sse4_2, neon, hasGgmlBaseline }
sourced from the new probe_cpu_features() helper. hasGgmlBaseline
is the one flag the Settings banner actually reads.
- parallelModeAvailable: placeholder false until Phase A.4 lands
the real GPU VRAM probe + GpuGuard semaphore permit logic.
Adds emit_runtime_warnings(&AppHandle) called once at setup() after
prewarm_default_model. Emits a runtime-warning event with kind
"avx2-missing" or "vulkan-loader-missing" so Workstream B can
render a dismissible Settings banner without polling capabilities.
Contract matches docs/whisper-ecosystem/workstream-A.md §Item #1 for
Workstream B to consume without waiting for Phase A.2 to land the
real whisper_print_system_info bridge.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Extends hardware::CpuInfo with a CpuFeatures struct populated via
std::is_x86_feature_detected! on x86_64 and an architectural
assumption for aarch64 (NEON). Adds has_ggml_baseline() so callers
can cheaply ask 'will whisper.cpp / llama.cpp ship a fast path on
this CPU?' without knowing the arch-specific rule.
The point of #7 is giving the runtime a way to surface a clear
"non-AVX2 fallback" warning before the user hits a wall of silent
slowness. The banner itself ships in the next commit as part of
get_runtime_capabilities (item #1). Tests cover the baseline helper
on both x86 and non-x86 targets.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Adds a build.rs guard that parses Cargo.lock and panics on Windows
if the tokenizers crate ever appears in the workspace dependency
tree, mirroring the MSVC C-runtime conflict that broke Whispering
v7.11.0 when they linked whisper-rs-sys + tokenizers in the same
binary.
On non-Windows hosts the guard downgrades to a cargo:warning so
cross-compilation or CI from Linux surfaces the issue before a
Windows build attempt actually panics.
No tokenizers crate is in the tree today; the guard is preemptive.
If we ever legitimately need HF tokenizers on Windows, the escape
hatch is an out-of-process sidecar (separate CRT).
Co-authored-by: jars <jakejars@users.noreply.github.com>
Adds http://127.0.0.1:* and ws://127.0.0.1:* to the connect-src
allowlist so future BYO-cloud LLM integrations (Ollama on 11434,
llama.cpp server on 8080, LM Studio on 1234, etc.) can fetch(...)
without tripping the CSP.
Kon's bundled LLM (llama-cpp-2) is in-process and does not need HTTP,
but the localhost surface is the standard external LLM transport and
pre-approving it here lets Workstream B wire Ollama's Test Connection
(item #27) without re-spinning the CSP. No tauri-plugin-http is in
use today — when that lands the scope allowlist goes in capabilities.
Matches the pain pattern from Vibe #438 (Tauri scope blocking
127.0.0.1 LLM endpoints).
Co-authored-by: jars <jakejars@users.noreply.github.com>
Sequences the 18 A-scope items from docs/whisper-ecosystem/brief.md
into four phases (pre-emptive patches, engine abstraction, streaming
correctness, LLM guard) with stop-for-review boundaries between each.
Lists the new command + event contracts (#1 activeComputeDevice,
#14 list_gpus/set_preferred_gpu, #24 tentative segment flag, #28
parallel-mode toggle) that Workstream B will consume.
Co-authored-by: jars <jakejars@users.noreply.github.com>
Both workflows had `workspaces: src-tauri -> target`, which tells
rust-cache the workspace lives at src-tauri/ and its target dir is
src-tauri/target. Neither is true: the workspace is defined at the repo
root (Cargo.toml:1–3 — members = ["src-tauri", "crates/*"]), so cargo
walks up and puts artifacts at ./target, not ./src-tauri/target.
Result: the cache action was saving an empty (or wrong) directory on
every run. Every CI run on every OS effectively started from a cold
build, which is the actual reason the Windows job appeared to compile
sqlx from scratch every push — it was compiling sqlx from scratch every
push.
Point the cache at the real workspace root. While here, drop the
`working-directory: src-tauri` on the cargo check step so the command
runs from the workspace root too; cargo finds the same workspace either
way, but running from root is consistent with the cache's view.
Expected impact: Windows check job drops from ~15–25 min cold-every-time
to ~2–3 min on warm runs, matching Linux/macOS behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sqlx 0.8's default feature set pulls in `any`, `macros`, `migrate`, and
`json`. Grepping the workspace confirms none of these are used — the
code calls sqlx::query() / query_scalar() at runtime, implements its own
migration sequencing in crates/storage/src/migrations.rs, is sqlite-only
(no `any` needed), and never derives FromRow / applies sqlx proc-macros.
Dropping them keeps only what's needed: runtime-tokio + sqlite.
Why it matters disproportionately on Windows: the `macros` feature pulls
sqlx-macros → sqlx-macros-core → proc-macro2 / syn / quote / async-trait
/ url / heck / dotenvy / sha2 / filetime. Each proc-macro crate on
Windows MSVC compiles to a .dll with a full linker invocation (slower
ABI than Linux/macOS proc-macro .so). Net: tens of seconds shaved off
every cold-cache CI run, compounding with the cache-path fix in the next
commit.
kon-mcp was already lean (default-features = false); matching that shape
across the workspace now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Specs the subset of the five-phase GPU kernel tuning roadmap that ships
without requiring ggml-dedup or agentic-search prerequisites:
- Phase 1 — Advanced → GPU Tuning settings panel (GGML env var toggles,
applied at startup before threads spawn).
- Phase 2 — kon-bench local autotuning CLI. Subprocess-based grid search
over env vars, outputs a ranked gpu-profile.toml.
- Phase 3-lite — kon-configs community repo. Manual-PR workflow (no CI
replay), fingerprint-matched fetch from Kon Settings.
Total ~7–10 days of focused work; captures roughly 85% of the eventual
value of the full roadmap. Phases 4–5 (custom SPIR-V drops + agentic
autotune) stay pinned in memory.
Includes the UX spec for the "one-click auto-optimise" flow: community
config check first (~15 s end-to-end), local benchmark fallback
(~8 min backgrounded), opt-in share-back via browser PR. Non-GPU users
see a clean "tuning doesn't apply" card with no nag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cloud agents (Codex / Opus in Cursor) can't read the author's local
Claude memory directory. This doc folds the non-negotiable ideology,
architectural decisions, what's already shipped, what's intentionally
deferred, and the file-ownership fence between Workstream A and B into
a single in-repo reference.
Kickoff prompts now point agents at docs/whisper-ecosystem/kon-context.md
as the primary read BEFORE brief.md, so the agents understand what NOT
to re-implement (already-shipped features) and what NOT to touch (the
other workstream's territory, the ggml dedup interim, etc.).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Survey of 10 OSS Whisper projects (whisper.cpp, whisper-rs, Handy, Buzz,
Whispering/Epicenter, faster-whisper, WhisperLive, whisper_streaming,
Scriberr, Vibe, OpenWhispr). Pins the cross-repo pain pattern matrix,
feature inventory with priority tags, streaming-specific findings, LLM
formatting findings, and a 31-item atomic task backlog — all URL-sourced.
Lives in the repo as the canonical reference for the phase-4 implementation
pass. Both Cursor workstreams (Codex for systems + streaming, Opus for UX
+ LLM layer) read this at kickoff.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings → Vocabulary gets a "Bulk add from a list…" disclosure under the
single-term row. Expanding reveals a textarea; paste newline- or
comma-separated terms, hit Import, and the page loops addTerm for each
entry the active profile doesn't already have.
Dedupes case-insensitively against the existing term list so pasting the
same block twice is a no-op. Skipped + failed counts surface via toast;
persistent errors (any failing term) also land in vocabularyError so the
inline panel explains what went wrong.
Covers OpenWhispr issue #460 — one-at-a-time entry becomes friction past
roughly ten terms. No backend changes; addTerm is already in profilesStore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without this, every secondary window (preview overlay, task float,
transcript viewer) opened at whatever spot Tauri / the compositor picked,
which was especially noticeable on Wayland where placement hints are
advisory. Main window's position was also lost on restart.
Registering tauri_plugin_window_state in the builder gives automatic
per-window-label save + restore. State lives in app-data/window-state.json;
fresh installs still fall back to the builder defaults (no changes to
inner_size on any of the four windows). Covers OpenWhispr issue #605 and
the broader UX pain.
No frontend changes — the plugin is purely backend. Regenerated ACL
manifests / desktop + linux schemas pick up the plugin registration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KWin reads _NET_WM_STATE_SKIP_TASKBAR for its Alt+Tab list, which OW-2
already wired via skip_taskbar(true) on the builder. On Hyprland, Sway,
and GNOME Mutter that's not always enough — some switchers still enumerate
the overlay. Classifying the window as gdk::WindowTypeHint::Utility signals
to the compositor that this is an assistive auxiliary surface, so switchers
and auto-tilers leave it alone. No behavioural change on KWin.
GTK3 only honours the type hint before the window maps, so the preview
builder now starts .visible(false); we grab the gtk_window() via Tauri's
escape hatch, set the hint, then show(). The existing hide/show on
re-open still works — hint is a property of the gtk::ApplicationWindow
and survives the cycle.
Added gtk = "0.18" and gdk = "0.18" as Linux-only deps. Both are already
pulled in transitively via webkit2gtk 2.0, so this is just surfacing them
by name — no new compile cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without _NET_WM_STATE_STICKY the preview overlay only renders on whichever
virtual desktop it opened on — switch desktops mid-dictation and the raw
transcription stream vanishes exactly when you need it (the whole point of
the overlay is that you're working in another app).
visible_on_all_workspaces(true) on the WebviewWindowBuilder sets STICKY via
GTK on X11/XWayland, which KWin + Mutter both honour. Combined with the
existing skip_taskbar(true) — KWin's default Alt+Tab list already respects
_NET_WM_STATE_SKIP_TASKBAR — the preview now behaves like the assistive
overlay it's meant to be: follows you, out of the way of window switchers.
Applied only to the preview window. Task-float and transcript-viewer are
primary surfaces that should stay on their own desktop, so they keep the
current behaviour.
Follow-up if dogfooding shows Alt+Tab clutter on non-KDE compositors: layer
a GTK WindowTypeHint::Utility via with_webview. Not needed for KWin.
Matches OpenWhispr's PR #183 shape for KDE Plasma.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase H landed the transcription preview overlay. Phase C landed auto-paste.
With both enabled the combo is broken on Wayland compositors (KWin, Mutter):
the overlay is always_on_top + visible at the moment paste_text fires its
Ctrl+V keystroke, and the compositor resolves the key to the topmost visible
window — which is the overlay, even though we built it with focused=false.
Net result: the transcript pastes into Kon instead of whatever app the user
was actually dictating into.
Fix, mirroring OpenWhispr's PR #246 shape: before trigger_paste_keystroke,
hide the transcription-preview window if it exists and is visible, then
sleep PREVIEW_HIDE_SETTLE_MS (80ms) so the compositor recomputes focus onto
the previously-focused app. No reshow — the user's confirmation is the text
appearing in the target app, not a fading overlay. The 80ms is enough on
KWin and Mutter; tunable if it shows up differently on other compositors.
paste_text now takes the tauri::AppHandle so it can reach the preview
window. Frontend invocation signature is unchanged (Tauri injects the
handle; the JS call site still passes { text }).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QC smoke sweep flagged two clippy -D warnings lints in code this branch
introduced:
- crates/core/src/process_watch.rs — collapsible_if on the meeting-pattern
match loop, merged the two conditions with &&.
- crates/mcp/src/lib.rs — let-else on the id unwrap that short-circuits a
notification, switched to ? since handle_message already returns Option.
All other clippy lints under -D warnings (audio/capture, hotkey/linux,
storage/file_storage, diagnostics, the duplicate-detection helpers in
live.rs) predate this branch and are out of scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ported the best bits of OpenWhispr's TranscriptionPreviewOverlay into Kon's
window conventions. Off by default — toggle in Settings → Output → "Floating
preview when Kon is unfocused". Opens only when the main window isn't
focused at the start of a recording, so it never adds noise when the user
can already see the transcript in the main surface.
Phase state machine (src/routes/preview/+page.svelte):
- listening — pulsing dot, no text yet
- live — animated bars + streaming raw Whisper output
- cleanup — accent bars while the LLM cleanup pass runs
- final — checkmark + formatted text + 4s auto-hide
Data plumbing: raw segment text is captured before post_process_segments in
live.rs (new raw_text field on LiveResultMessage) and in transcription.rs
(new raw_text in the transcription-result payload). DictationPage forwards
raw_text to the overlay via Tauri global events — preview-listening on
start, preview-append per chunk, preview-cleanup before the LLM pass,
preview-final with the formatted text, preview-hide when a run produced no
transcript (empty recording / cancel).
Window is always_on_top, skip_taskbar, focused=false so it never steals
focus from whatever the user is dictating into. open_preview_window shows
an existing hidden preview or builds it fresh; close_preview_window hides
without destroying so the next open is instant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
search_transcripts already backs onto the transcripts_fts virtual table
(migration v4, trigger-maintained) via MATCH + ORDER BY rank. Adding a
test to lock the behaviour: token matching is case-insensitive, rank-
ordered, and non-matching tokens return nothing. This is Phase G of the
post-OpenWhispr audit — semantic embeddings stay deferred until the FTS
experience actually hits a wall.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default off. When on, the layout polls detect_meeting_processes every 15s
with the user's app-name patterns. On a fresh match (edge-triggered — no
re-toast until the app goes away and comes back) we fire a reminder toast
that tells the user which meeting app appeared and their global hotkey. We
never start recording on this signal; the ideology rule says the user
decides. The signal is a single channel: process list match only — no mic
activity heuristic, no calendar.
Backend adds kon_core::process_watch::{list_running_process_names,
match_meeting_patterns} over sysinfo, exposed to the frontend as the
detect_meeting_processes Tauri command.
Settings ships two new fields — meetingAutoCapture (bool) and
meetingAutoCaptureApps (string[]) — with a comma-separated input in the
Output section. Default app list is ["zoom", "teams"], user-editable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New workspace binary crates/mcp exposes Kon's SQLite store to external
agents (Claude desktop, Cline, any MCP client) without running the Tauri
app. Newline-delimited JSON-RPC 2.0 on stdio, MCP protocol 2024-11-05.
Tools shipped (all read-only):
- list_transcripts — recent transcript summaries, limit 1..200 default 20
- get_transcript — full text + metadata by id
- search_transcripts — FTS5-backed query, limit 1..100 default 20
- list_tasks — all tasks (open + done)
No writes. The Tauri app remains the only writer; kon-mcp just opens the
same SQLite file (via kon_storage::init) and reads. Logs land on stderr to
keep stdout clean for the JSON-RPC stream. Smoke-tested end-to-end with
initialize + tools/list over a pipe.
Wire into an MCP client with:
{ "mcpServers": { "kon": { "command": "/path/to/kon-mcp" } } }
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
initI18n (src/lib/i18n/index.ts) registers three locales and picks the
initial one in order: kon_locale in localStorage > navigator.language short
code > en. +layout.svelte calls it once at app boot; guarded so per-window
re-init is a no-op.
Locale files are deliberately sparse — this is a scaffolding pass so strings
can be migrated incrementally. The Settings → Appearance → Language picker
plus its own description is the first real consumer; everything else
continues to render as hardcoded text until extracted.
Also: split the @chenglou/pretext ambient shim into src/lib/shims.d.ts. The
declaration previously lived in app.d.ts alongside a top-level `export {}`,
which made app.d.ts a module — scoping `declare module` to its own imports
and breaking resolution from src/lib/utils/textMeasure.ts. The fresh
.svelte-kit sync triggered by installing svelte-i18n surfaced it. Ambient
shim files must stay script-scoped (no top-level imports/exports).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an opt-in "auto-paste into focused window" toggle. When enabled, the
dictation pipeline sets the clipboard and then sends a Ctrl+V / Cmd+V
keystroke to whatever window currently has focus — the common case after a
global-hotkey dictation, since Kon's own window never stole focus.
Backend (src-tauri/src/commands/paste.rs) probes for a platform paste tool
and falls back cleanly:
- Linux Wayland: wtype > ydotool > xdotool
- Linux X11: xdotool > ydotool > wtype
- macOS: osascript System Events keystroke
- Windows: PowerShell WScript.Shell SendKeys
detect_paste_backends is a pure probe used by Settings to describe the
available backend next to the toggle (or nudge the user to install one).
paste_text always copies first, so auto-paste failure degrades to the
existing clipboard-only behaviour and surfaces a warn toast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Parakeet-TDT scores 85 on any GPU-equipped English-capable system (Instant
speed + Great accuracy + GPU boost + headroom) vs ~75 for the best distilled
Whisper. A new test in recommendation.rs locks this in so future scoring
tweaks don't silently regress it.
FirstRunPage previously stored settings.modelSize by title-casing a lowercased
alias — which worked for Tiny/Base/Small/Medium but produced
"Whisper-distil-small-en" for the new distil ids. Swap to an id→label map
and pass the raw model id through to download_model/load_model; the backend
already accepts full ids via the whisper_model_id fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new registry entries (crates/core/src/model_registry.rs):
- whisper-distil-small-en — 336 MB, ~6× faster than whisper-small-en
- whisper-distil-large-v3 — 1.55 GB, near large-v3 accuracy at medium size
Both are whisper.cpp-compatible GGML binaries hosted on HF by the
distil-whisper org; no runtime change, just wider model choice. English-only
by design (matches upstream Distil-Whisper).
The Settings model picker widens to six options — Tiny, Base, Small,
Distil-S, Medium, Distil-L — ordered roughly by accuracy. Download/load
commands now take the resolved model id (whisper-distil-*) instead of the
lowercased label, so the frontend owns the label↔id mapping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously profile_terms only reached the LLM cleanup stage as the
dictionary_terms suffix. Whisper decoded without any vocabulary hint, so
domain names ('Wren', 'CORBEL') were misspelled on the first pass and the
LLM had to guess at the correction.
build_initial_prompt (src-tauri/src/commands/mod.rs) collapses caller /
profile / terms into a single Whisper prompt:
caller_prompt > profile_prompt + "Vocabulary: <terms>." > None
transcribe_pcm, transcribe_file, and start_live_transcription_session all
route through the helper, so the three paths stay in lockstep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kon-llm now owns a real LlamaBackend + LlamaModel, with three Qwen3 tiers
(1.7B Q4, 4B-Instruct-2507 Q4, 14B Q5) selectable per hardware. Downloads
are resumable with SHA-256 verification and stored under ~/.kon/models/llm.
Engine exposes three high-level surfaces — all greedy/temp-0, GBNF-constrained
where output shape matters:
- cleanup_text (prompt-injection-hardened system prompt; profile terms
appended as "preserve these spellings" suffix)
- decompose_task (3–7 micro-steps, constrained JSON array)
- extract_tasks (optional-array; empty when no explicit commitments)
post_process_segments now takes an Option<&LlmEngine> and, when loaded and
format_mode != Raw, joins segments → cleanup → replaces segments with the
cleaned text (first segment span). Rule-based path still runs first; LLM
errors log and keep rule-based output.
Tauri commands: recommend_llm_tier, check_llm_model, download_llm_model,
load_llm_model, unload_llm_model, delete_llm_model, get_llm_status,
cleanup_transcript_text_cmd, extract_tasks_from_transcript_cmd,
decompose_and_store (LLM-backed subtasks).
Settings: AI tier toggle (off / cleanup / tasks), model picker with
downloaded/loaded status, download progress events via
kon:llm-download-progress.
Dictation: ensureLlmModelLoaded on mount, cleanupTranscriptIfEnabled after
stop when tier != off and format_mode != Raw, LLM task extraction when
tier=tasks (regex fallback on failure).
Interim: both llama-cpp-sys-2 and whisper-rs-sys statically link their own
ggml, so src-tauri/build.rs emits -Wl,--allow-multiple-definition on Linux.
Replace with a system-ggml shared-lib setup as a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Major quality pass on top of Phase 2. Five substantive changes plus
cross-cutting touches across audio, hotkey, transcription, and Tauri
command layers.
Transcription quality
- Long-audio chunking in commands/transcription.rs: Parakeet and large
file transcription now chunk-and-recompose with overlap trimming, so
the live-path chunking advantage extends to file-based workflows.
- Stateful live speech gate in commands/live.rs on top of the earlier
duplicate-boundary filtering — distinguishes start-of-speech from
mid-speech and holds state across chunks.
Auto-learning corrections
- New crates/ai-formatting/src/correction_learning.rs: extracts user
text corrections from viewer edits and proposes additions to the
active profile's vocabulary.
- src-tauri/src/commands/profiles.rs bridge for frontend-driven
confirmation of learned terms.
- src/routes/viewer/+page.svelte hooks the learning path into the
segment-edit flow so corrections feed profile_terms without a
separate 'train this profile' UX.
Transcript profile provenance
- Migration v8 (crates/storage/src/migrations.rs) adds profile_id to
transcripts, defaulting to DEFAULT_PROFILE_ID so existing rows stay
valid.
- crates/storage/src/database.rs: TranscriptRow + CRUD carry profile_id.
- src-tauri/src/commands/transcripts.rs: add_transcript accepts and
persists profile_id.
- DictationPage.svelte + FilesPage.svelte send activeProfileId on
capture so learned corrections are attributed to the right profile.
Cleanup prompt contract
- crates/ai-formatting/src/llm_client.rs hardened: the CLEANUP_PROMPT
now specifies concrete do/do-not rules, ready for a real model-backed
cleanup pass. The llm_client is still a stub — kon-llm remains unwired
— but the prompt shape is final.
Cross-cutting polish
- Minor touches in audio (capture/decode/resample), hotkey (lib/linux/stub),
core, transcription (concurrency/model_manager/local_engine/whisper_rs),
and the rest of src-tauri/src/commands/*: error-path tightening, log
clarity, TS-migration follow-ups (@ts-nocheck additions for incremental
typing).
Verified locally: npm run check, cargo test -p kon-ai-formatting,
cargo test -p kon-storage, cargo test -p kon --lib commands::live::tests,
cargo check — all green.
Scope boundary: kon-llm crate is still a stub; task extraction remains
rule-based. Bundled local-LLM runtime is the next clean step and is not
in this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the status-line hint next to the record button and the empty-state
message now read settings.globalHotkey reactively, so 'Press record or
Super+E' (or whatever the user has bound) stays in sync with the actual
shortcut.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
webkit2gtk under KDE Wayland does not set e.metaKey on subsequent events
after the compositor intercepts Super — but the Super keydown itself is
still delivered with e.key === "Super". Track pressed modifiers from
raw keydown/keyup and OR with the webkit booleans so combos like Super+E
(which OpenWhispr already registers successfully via evdev) can now be
captured in Kon's recorder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three correctness fixes to the hotkey recorder based on dogfood logs:
1. Modifier set now includes Super / OS / Hyper (webkit2gtk on Linux
fires e.key === "Super" for the Windows key — previously that key
got captured as the final trigger, producing invalid 'Ctrl+Shift+Super'
strings the evdev parser rejected).
2. resolveTriggerKey() uses e.code (physical, shift-independent key)
to resolve shifted punctuation back to the unshifted name the evdev
parser understands: '+' -> '=', '|' -> '\\', etc. Letters and digits
also use e.code (KeyA -> A, Digit1 -> 1) to avoid layout quirks.
3. Numpad keys intentionally not mapped to main-keyboard equivalents —
they are distinct evdev codes. Leaves the parser to reject them so
the user gets a toast instead of a silently-wrong binding.
Registration failures now surface as a toast and revert
settings.globalHotkey to the last successfully-registered value (if
any), so the UI cannot lie about what is actually bound.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 16 of Phase 2 Remaining. Removes the three global-dictionary Tauri
commands now that all frontend callers were migrated to the profile-scoped
equivalents in Task 15:
- list_dictionary_command
- add_dictionary_entry_command
- delete_dictionary_entry_command
Also drops the DictionaryDto and its From<DictionaryEntry> impl (dead
alongside the commands), plus the now-unused kon_storage imports
(list_dictionary, add_dictionary_entry, delete_dictionary_entry,
DictionaryEntry). The storage-layer functions and the dictionary table
itself stay until Task 17 drops them.
Codex verification point 5 cleared: zero frontend callers for the legacy
commands (or their _cmd aliases) before deletion.
cargo check -p kon: clean.
cargo test --workspace: 40 passed; pre-existing ensure_x11_on_wayland
doctest failure at src-tauri/src/lib.rs:77 unchanged.
Wholesale JS -> TS migration of the frontend — stores, utils, actions,
and all Svelte component scripts adopt type annotations. Compile-time
surfaces (app.d.ts, lib/types/) added for shared DTO types.
Build plumbing:
- package.json: dev:frontend script that runs svelte-kit sync first
- tauri.conf.json: beforeDevCommand points at dev:frontend
- run.sh: dropped the sed-hack that temporarily blanked beforeDevCommand;
now relies on npm run dev:frontend to avoid double-Vite
- jsconfig.json: allowImportingTsExtensions
Preserves all Group 1 behaviour:
- page.svelte.ts keeps loadHistory / loadTasks Tauri-first, no
localStorage; saveTranscriptMeta + mapTranscriptRow + mapTaskRow
intact; update_task_cmd and update_transcript_meta_cmd invocations
carry the correct payload shape.
- Toasts, preferences stores typed without behaviour change.
- Viewer still routes segment edits through saveTranscriptMeta; the
Task 1.5 TODO markers are gone.
taskExtractor.ts is functionally improved during the migration:
- multi-task matches in the same sentence
- list-style shopping-verb expansion (get bread, milk, and cheese)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ai-formatting:
- rule_based.rs: collapse_repetitions() merges adjacent duplicate
tokens like 'I I can' -> 'I can' and 'think think that' -> 'think
that'. Normalises case and punctuation before comparison.
- pipeline.rs: post_process now calls collapse_repetitions when
format_mode is Clean or Smart. Added unit coverage.
audio:
- capture.rs: replace the seven deprecated cpal DeviceTrait::name()
call sites with a device_display_name() helper that uses the
non-deprecated description() path. Keeps identical behaviour,
silences compile warnings, ready for cpal upgrade.
Addresses the 'Christ. Christ.' live-transcription boundary duplicate
Jake saw during Group 1 dogfooding. Does not fix all cross-chunk
overlap cases (see live.rs OVERLAP_SAMPLES for the root cause) but
catches the common stutter pattern at post-processing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tailwindcss/vite:generate:serve threw 'Invalid declaration:
getCurrentWindow' on ResizeHandles.svelte?svelte&type=style&lang.css
even after comment sanitisation. The style block is plain CSS with no
Tailwind directives, but the per-component virtual CSS module route
was hitting a parse bug in the Tailwind v4 + Svelte 5 combination.
Workaround: move the CSS into app.css (class names are already
component-specific, no scoping loss) and drop the component-local
<style> block. This sidesteps the virtual-module route entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 92ac7ea. Two apostrophes remained (KWin's and don't)
that kept tripping Tailwind v4 JIT the same way. Removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
transcribe-rs 0.3.10's ParakeetModel::transcribe_raw ignores its
options argument and calls self.infer(samples, &TimestampGranularity::default())
where default is TimestampGranularity::Token — per-subword segments.
That surfaces in Kon as output like 'T Est Ing One , Two , Three . W Ow ,
This Is T Ri Ble .' because DictationPage joins segment texts with ' '.
Introduce a thin ParakeetWordGranularity wrapper that implements
SpeechModel and overrides transcribe_raw to call the concrete
ParakeetModel::transcribe_with() with ParakeetParams { timestamp_granularity:
Some(Word) }. Pre-existing bug unrelated to Phase 2 work — surfaced during
Group 1 dogfooding because Parakeet was being tested for the first time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to d1d344b. Tailwind's scanner still saw backtick-quoted
fragments like `decorations: false` and `position: fixed` in the
script comments as CSS property declarations, tripping on the next
JS identifier (getCurrentWindow). Removing all backticks from the
comment block sidesteps the entire scanner path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tailwind v4 JIT scanner mis-tokenised the comment 'Tauri`s `data-tauri-drag-region`'
as an unterminated string literal, breaking dev-server HMR. Comment rewritten
to avoid apostrophes near backticks. No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second dogfood sprint. Headline fix: Linux now uses native KWin/Mutter
decorations instead of fragile frameless `startResizeDragging`, which
collapsed diagonal corner resize to a single axis and made drag feel
laggy. macOS / Windows keep custom chrome via `useCustomChrome` gate.
Other changes:
- Cross-window preferences sync via `kon:preferences-changed` Tauri
event — theme and font changes propagate live to float/viewer.
- Hotkey recorder rewritten to use capture-phase document listener
gated by $effect; button focus was unreliable in webkit2gtk.
- History page redesigned for cognitive-load hygiene: title-first
compact row, inline title input, Edit popout opening /viewer in
edit mode, clipboard export as .md with YAML frontmatter, manual
tag chips + + Add tag input, header tag filter (cap 7), global
Starred filter, `tag:xyz` search syntax.
- `deriveAutoTags` kept as empty hook for post-Task-7 LLM topic tags;
research found all previous auto-tag chips redundant with row UI.
- Viewer window adds edit mode with debounced-save textarea; native
title renamed to "Kon - Transcription Editor".
- Window minimums updated per GNOME HIG + WCAG reflow research:
main 960x600, float 360x480, editor 560x520.
- Microphone picker filters raw ALSA strings (hw:, plughw:, front:,
sysdefault:, null) and dedupes by CARD=X. New `description` field
on DeviceInfo reads /proc/asound/cards so Blue Yeti shows as "Blue
Microphones" instead of the short "Microphones" card name.
- GPU reporting fixed: get_runtime_capabilities now returns
accelerators=[cpu,vulkan] and whisper.supports_gpu=true, matching
the transcribe-rs whisper-vulkan feature linked unconditionally.
- ResizeHandles kept for macOS/Windows frameless: 12px edges, 20px
corners via CSS vars, pointerdown + setPointerCapture, corners
above edges in z-order, rendered as sibling (not child) of the
animated layout root so `position: fixed` is viewport-relative.
- Dueling drag-region handlers removed — `data-tauri-drag-region` and
manual `startDragging()` were stacked on the same elements; kept
the manual handler which has the button/input early-return logic.
See HANDOVER.md for the full session log and deferred items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replacing the preferences object (spread reassignment) caused all consumers'
prefs references to go stale — the loop guard read the old value forever.
Svelte 5 shared state pattern requires a stable const object with property
mutations, not reassignment. Also removes duplicate theme sync $effect from
SettingsPage (already handled in +layout.svelte).
HANDOVER.md was a working-notes file from the 2026/04/04 session that
was never committed. It described live transcription as broken — which
was fixed in the 2026/04/17 sprint. Leaving it untracked was a trap
for anyone cloning the repo. Deleted.
.firecrawl/ added to .gitignore (web-scraping cache, no repo value).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
.github/workflows/check.yml: cargo check on Linux + Windows + macOS
plus Svelte/Vite build on every push to main and every PR. Fast
feedback without paying for the full release build.
.github/workflows/build.yml: cross-platform release build producing
.AppImage/.deb (Linux), .msi/.exe (Windows), .dmg (macOS). Triggers
on v* tag push (attaches to a draft GitHub Release) or manual
workflow_dispatch. Signing stubs commented out pending cert decisions.
run.sh: dev launcher that starts Vite, waits for port 1420, then runs
tauri dev with beforeDevCommand blanked to avoid a second Vite spawn.
Restores tauri.conf.json on exit via trap.
src-tauri/gen/schemas/linux-schema.json: Tauri-generated capability
schema for Linux; the other platform schemas (desktop, windows) were
already committed. Adds the missing sibling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
textMeasure.js, virtualList.js, and accessibilityTypography.js were
added in the 2026/04/04 session alongside VirtualSegmentList.svelte but
never committed. All four are imported from DictationPage, HistoryPage,
SettingsPage, and the viewer — a fresh clone had unresolvable imports.
textMeasure.js: pretext-backed text measurement with LRU cache.
measureTextHeight, measurePreWrap, clampTextLines, invalidateCache.
virtualList.js: binary-search visible range for variable-height virtual
lists. buildCumulativeOffsets + findVisibleRange.
accessibilityTypography.js: pretext font/line-height helpers that read
the user's accessibility preference store (Lexend / Atkinson /
OpenDyslexic mapping).
VirtualSegmentList.svelte: virtualised transcript segment renderer used
by the viewer page; depends on all three utils above.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
crates/storage/src/migrations.rs:
Replace naive sql.split(';') with split_statements() that tracks
BEGIN...END depth, so migrations containing trigger definitions
execute correctly instead of being split mid-block.
crates/transcription/src/lib.rs:
Re-export transcribe_rs::SpeechModel so callers in src-tauri can
reference it without adding transcribe-rs as a direct dependency.
src-tauri/src/commands/models.rs:
Use Box<dyn SpeechModel + Send> as the load_model_from_disk return
type, matching the trait object that transcription crates produce.
src-tauri/src/commands/transcripts.rs:
Remove the stale .map(|v| v as i32) casts on sample_rate and
audio_channels. InsertTranscriptParams now stores these as i64
(ebf449b), matching the i64 fields on CreateTranscriptRequest;
casting to i32 first would silently truncate large sample rates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Front-to-back QC pass turned up two independent missing-module
showstoppers (workspace did not compile; frontend did not load) plus a
handful of HANDOVER-claimed features that were wired but dead. Fixes:
P0 — gets the app booting again:
- Add the never-committed src/lib/utils/runtime.js (hasTauriRuntime
detection); 5 imports were resolving to nothing.
- Add the never-committed crates/audio/src/streaming_resample.rs
(rubato-backed StreamingResampler with new/push_samples/flush);
declared in lib.rs and used 3x by live.rs but had no impl.
- Drop the duplicate hasTauriRuntime import in routes/+layout.svelte.
- Allow the transcript-viewer window to use the default capability
(was missing from capabilities/default.json:windows, so the viewer
window could open but not invoke any Tauri command).
P1 — features documented as working but actually dead:
- Pump MicrophoneCapture::take_error_rx() into LiveStatusMessage::
Warning each loop iteration in commands/live.rs. The HANDOVER
promised cpal stream errors would surface as toasts; the channel
was created and never read.
- Replace .expect() on the WebKit media-permission setup with a
logged warning. Failure no longer aborts the whole process.
- Toast on save_preferences failure (preferences.svelte.js had a
silent console.error — now warns once per failure run via the
existing toasts store).
P2 — correctness/robustness:
- add_dictionary_entry: switch INSERT OR IGNORE to ON CONFLICT
DO UPDATE ... RETURNING id so duplicate terms get the real row id
instead of a stale auto-increment.
- search_transcripts: qualify ORDER BY fts.rank.
- InsertTranscriptParams + TranscriptRow: bump sample_rate /
audio_channels from i32 to i64 to match the Tauri DTO and avoid
silent truncation at the boundary.
- Drop the unused tauri-plugin-mcp dependency.
- Promote sqlx in src-tauri/Cargo.toml from linux-only to
unconditional (lib.rs names sqlx::SqlitePool unconditionally —
macOS/Windows builds were latently broken).
- hotkey/linux.rs: stop panicking the hotplug task on inotify
failure; degrade to "no hotplug" with a stderr warning.
- layout.svelte: store the global error/unhandledrejection handler
refs and remove them in onDestroy so HMR/window teardown doesn't
leak listeners.
Verified: cargo check -p kon-core -p kon-storage -p kon-cloud-providers
passes. cargo check on src-tauri/kon-audio/kon-hotkey requires alsa +
gtk system libs not present in this sandbox; their changes are
syntactically and type-checked against the rest of the workspace.
svelte-check requires npm install which is not available here.
https://claude.ai/code/session_018ozAs4UcRC8jbJbddqJtEw
CROSS-PLATFORM AUDIT:
- Linux x86_64 (Fedora 43, KDE Wayland): HIGH confidence (the dev target)
- Linux other distros / X11: MEDIUM (tested patterns, untested distros)
- Windows 10/11: LOW — theoretically supported via Tauri + CPAL + whisper.cpp
+ tauri-plugin-global-shortcut (the custom evdev hotkey is no-op on
Windows); has had zero hands-on testing
- macOS Apple Silicon / Intel: LOW — same; macOS Info.plist needs
NSMicrophoneUsageDescription for the bundled app, and the path bug
fixed in this commit
REAL BUG FIXED — macOS app_data_dir was Unix-style:
crates/storage/src/file_storage.rs::app_data_dir() previously fell into
the "Unix" branch on macOS and wrote to ~/.kon/, which violates Apple
guidelines and confuses install/uninstall tooling. Now correctly:
- Windows: %LOCALAPPDATA%/kon (unchanged)
- macOS: ~/Library/Application Support/Kon/
- Linux: $XDG_DATA_HOME/kon or ~/.local/share/kon (XDG Base Directory),
with legacy ~/.kon/ fallback so existing installs keep working
- Other Unix: ~/.kon/ (unchanged)
OS DETECTION LAYER:
- New get_os_info Tauri command in commands/diagnostics.rs returns:
{os, arch, family, usesCmd, isWayland, customHotkeyBackend,
primaryModifierLabel}
- New src/lib/utils/osInfo.js helper:
- async loadOsInfo() warms a cache, called once at root layout mount
- then isMac() / isWindows() / isLinux() / modKeyLabel() / isWayland()
are synchronous
- browser-preview fallback reads navigator.platform
- Lets components localise hotkey labels (Cmd vs Ctrl), file-picker
copy ("Open Finder" vs "Open Explorer"), etc, without recomputing in
every place.
cargo check -p kon-storage clean. Updated HANDOVER-2026-04-17.md with
a per-platform confidence table.
THE BRIEF:
For the friends beta we need verbal feedback AND technical feedback —
some bugs the user cannot describe but a stack trace can. Built it in
two layers, with Layer 3 deferred until there is real volume.
PRIVACY POSTURE (matches Kon's local-first positioning):
- All capture is to disk only. Nothing is transmitted.
- The manual report bundler shows the user exactly what would be
shared and lets them choose to copy / save it.
- No remote endpoint, no Sentry, no opt-out telemetry.
LAYER 1 — Always-on local capture:
- Rust panic hook (commands/diagnostics.rs::install_panic_hook) writes
each panic to ~/.kon/crashes/<unix-ts>-<short-id>.crash. Captures
thread name, OS/arch, RUST_BACKTRACE state, and the panic info.
Installed before tauri::Builder so it catches setup-time panics too.
- Frontend window.onerror + window.unhandledrejection in
src/routes/+layout.svelte forward to the new log_frontend_error
Tauri command, which inserts into the existing error_log SQLite
table. Best-effort: errors in the error handler itself are swallowed
so logging can never crash the app.
- Existing error_log table (migration v1) is now actually used —
closes the TODO from crates/storage/src/database.rs:409.
LAYER 2 — Manual diagnostic report:
- Settings → About → Diagnostics section: Generate report → Preview →
Copy / Save.
- Report is plain markdown so it pastes cleanly into email, Discord,
GitHub issues. Sections: app version + OS, sanitised settings JSON,
recent error_log rows, last 5 crash dumps with previews, log file
tail (8KB).
- Preview is shown in a <details> with the full text the user can
inspect before deciding to share.
- Save writes to ~/.kon/diagnostic-reports/kon-diagnostic-<ts>.md.
NEW FILES:
- src-tauri/src/commands/diagnostics.rs (panic hook + 5 Tauri commands)
CHANGES:
- crates/storage/src/file_storage.rs: crashes_dir() + logs_dir() helpers
- crates/storage/src/database.rs: ErrorLogRow + list_recent_errors()
- crates/storage/src/lib.rs: re-exports
- src-tauri/src/commands/mod.rs: + diagnostics module
- src-tauri/src/lib.rs: install_panic_hook() before Builder; 5 new
Tauri commands registered (log_frontend_error,
list_recent_errors_command, list_crash_files,
generate_diagnostic_report, save_diagnostic_report)
- src/routes/+layout.svelte: installGlobalErrorCapture() at mount
- src/lib/pages/SettingsPage.svelte: diagnostic state + buttons +
preview block at the end of the About section
cargo check -p kon-storage clean. Settings.svelte if/each balanced.
LAYER 3 (deferred):
- Optional opt-in remote reporting (self-hosted Sentry on Tartarus or
similar). Not for friends beta. Open up after volume justifies it.
VOCABULARY PANEL (Day 5):
src/lib/pages/SettingsPage.svelte gains a new collapsible "Vocabulary"
section between Audio and Transcription. Wires the dictionary commands
shipped in 1cce567:
- list_dictionary_command on mount + onfocus refresh
- add_dictionary_entry_command (term + optional note)
- delete_dictionary_entry_command
- Inline error feedback via vocabularyError
- Empty-state copy + per-entry remove button with aria-label
The dictionary terms are intended to be injected into the LLM cleanup
prompt so the model preserves user-specific vocabulary (medication
names, place names, jargon, names of people in the user's support
network — particularly important for the ND audience). The LLM client
itself is currently a stub (crates/ai-formatting/src/llm_client.rs);
when it lands, it can import list_dictionary from kon_storage and
inject terms into the prompt suffix.
WAYLAND SELF-RELAUNCH (Day 6):
src-tauri/src/lib.rs gains ensure_x11_on_wayland() which runs before
tauri::Builder. On Linux Wayland sessions (XDG_SESSION_TYPE=wayland) it
sets GDK_BACKEND=x11, WINIT_UNIX_BACKEND=x11, and
WEBKIT_DISABLE_DMABUF_RENDERER=1 — the env vars HANDOVER.md documents
the user manually prefixing.
Drops the "users have to remember the env-var prefix" friction.
Idempotent: existing values are respected. Inspired by Open-Whispr's
Chromium --ozone-platform=x11 self-relaunch.
Compile-checked: cargo check -p kon-storage clean. Settings.svelte
$state / #if balanced (28 state, 34/34 if/endif).
NEXT: Phase 6 — dogfooding readiness doc with Jake test instructions.
addToHistory, renameHistoryEntry, deleteFromHistory in
src/lib/stores/page.svelte.js now dual-write to:
- the in-memory `history` array (UI snappiness, unchanged)
- localStorage (offline / browser-preview fallback, unchanged)
- SQLite via the new Tauri commands from 1cce567 (the canonical store)
The SQLite write is best-effort: failure does not lose the in-memory
copy. Console-warns for diagnostic visibility. Browser-preview path
correctly skips the Tauri call.
HistoryPage rename flow (renameItem) now calls renameHistoryEntry, which
goes through update_transcript. Closes the long-standing TODO from
architecture-review.md §13: rename was UI-only, never persisted, lost
on reload.
On rename failure a warn-toast surfaces "your change is visible now but
did not save" so the user is not surprised on next launch.
NOT WIRED YET (deferred):
- HistoryPage.searchQuery still filters in-memory rather than calling
search_transcripts. Fine for small histories; FTS5 infrastructure is
in place to upgrade when needed.
- Reading initial history from SQLite on session boot. localStorage
remains the cold-start source for now; SQLite catches up via dual-
write. A backfill / one-time sync command can land later.
Finally exposes the canonical SQLite store to the frontend. Previously
the transcript/task tables existed but no Tauri command read or wrote
them — the UI lived entirely in localStorage. Closes that gap and adds
the missing commands flagged by both Codex and architecture-review.md
§13.
MIGRATIONS:
- v2 adds:
- transcripts_fts virtual table (FTS5, porter+unicode61 tokeniser,
diacritics-folded) backed by transcripts via content_rowid
- INSERT/UPDATE/DELETE triggers keep FTS in sync automatically
- dictionary table for custom vocabulary (term, note, created_at)
- Append-only as required; never modifies v1
STORAGE FUNCTIONS (crates/storage/src/database.rs):
- list_transcripts_paged(limit, offset) — pagination
- count_transcripts() — for "showing X of N" in UI
- update_transcript(id, text?, title?) — closes the historic
architecture-review.md §13 rename-never-persists TODO
- search_transcripts(query, limit) — FTS5 wrapper
- list_dictionary / add_dictionary_entry / delete_dictionary_entry
- DictionaryEntry struct exported
TAURI COMMANDS (src-tauri/src/commands/transcripts.rs new file):
- add_transcript, list_transcripts, count_transcripts_command,
get_transcript, update_transcript, delete_transcript,
search_transcripts
- list_dictionary_command, add_dictionary_entry_command,
delete_dictionary_entry_command
- TranscriptDto + DictionaryDto for camelCase frontend serialisation
REGISTRATION (src-tauri/src/lib.rs):
- All 10 new commands wired into the invoke_handler
cargo check -p kon-storage passes clean. The Tauri crate cargo check
still requires `sudo dnf install cmake clang-devel` for whisper-rs-sys
(pre-existing infra dep, unrelated to this work).
NEXT (still in this sprint):
- Wire DictationPage / FilesPage to call add_transcript on every save
(dual-write: keep localStorage for now, add SQLite alongside)
- Wire HistoryPage rename to call update_transcript (fixes the TODO)
- Add a History search input that calls search_transcripts
- Add Settings → Dictionary panel
- Inject dictionary terms into the LLM cleanup prompt
THE PROBLEM (Codex review + architecture-review.md §1, §14):
Multiple critical paths discard errors silently — `let _ = insert_transcript(...)`,
`.catch(() => {})`, inline `error = ...` state that not every page surfaces. ND
users in particular need explicit feedback when something fails; silent
failure is worse than no feature.
SHIPPED:
src/lib/stores/toasts.svelte.js (new):
- Minimal in-house toast store (~80 lines, no svelte-french-toast dep)
- Severity → palette: info/success → moss, warn → signal, error → ember
- Auto-dismiss durations per severity (success 3s, info 4s, warn 6s,
error sticky)
- `invokeWithToast(invoke, command, args, errorTitle?)` helper wraps a
Tauri invoke and toasts on failure while still throwing for callers
that need to handle the error themselves
src/lib/components/ToastViewport.svelte (new):
- Reads from the toasts store
- Bottom-right stack, max-width clamp on small screens
- aria-live=polite + role=alert on error toasts (screen-reader friendly)
- Slide-in animation honours `html.reduce-motion` for the existing
preferences toggle
- Severity left-border in brand colours via existing CSS variables
src/routes/+layout.svelte:
- Mounts <ToastViewport /> once at the app root so toasts work from any
page or window
src/lib/pages/DictationPage.svelte:
- First error-toast wiring: failures starting recording now show a
sticky toast in addition to the inline `error` panel
- Replaces a previously easy-to-miss failure-mode
NEXT (Day 4 batch):
- Wire toasts into FilesPage, HistoryPage, SettingsPage, ModelDownloader
(all currently swallow errors)
- Add `add_transcript`, `list_transcripts`, `update_transcript` (closes the
long-standing TODO Codex flagged), `delete_transcript` Tauri commands
wrapping the existing storage layer
- Switch addToHistory to dual-write (localStorage + SQLite) so the
canonical store catches up with the actual transcript flow
- FTS5 transcripts_fts virtual table + search command
- Wrap multi-row writes (decompose_and_store) in DB transactions
Closes the 6 Codex review findings on the Day 1 mic-capture work
(commits 96980c7 + 41db162). Detail in
output/reports/kon-codex-mic-capture-followup-2026-04-17.md (CORBEL
workspace).
src-tauri/src/commands/audio.rs:
- D1: Sending an explicit stop signal before dropping stop_tx, so the
accumulator task wakes up immediately rather than waiting for the
Disconnected detection added below.
- D2: Wrapping MicrophoneCapture::start() in tokio::task::spawn_blocking.
start() can spend up to 350ms × N_devices × 2 passes; running it on
the async runtime froze other Tauri commands.
- M3: Match on rx.try_recv() error variants. Empty -> sleep + continue.
Disconnected -> exit accumulator task immediately. Previous behaviour
was an infinite loop after the capture stream died.
crates/audio/src/capture.rs:
- D3: Added DEAD_SILENCE_FLOOR (1e-7) gate that applies even in the
fallback pass. PulseAudio/PipeWire can stream zero-valued bytes from
a borked device; that is worse than failing fast.
- M1: Validation requeue (`for chunk in collected { try_send }`) now
counts drops in the same dropped_chunks counter.
- M2: New CaptureRuntimeError type. The cpal stream error callback now
forwards errors via a separate sync_channel (capacity 16) that the
live session can drain via take_error_rx() and surface as toasts.
Re-exported from kon_audio crate root.
cargo check -p kon-audio passes clean.
NOT YET DONE (M4): JACK-specific monitor name patterns. Defers until
testing on a JACK host. Current is_monitor_name() may miss JACK
conventions.
Wiring the new error_rx into the live session and surfacing as toasts
lands with the Day 3 error-toast system commit.
Day 1 follow-up to 96980c7. The device-picker UI in Settings now
actually takes effect: settings.microphoneDevice flows from the Svelte
store, through the Tauri invoke, into MicrophoneCapture::start_with_device
on the Rust side.
Touched paths (back-to-front):
- src-tauri/src/commands/audio.rs:start_native_capture — new optional
`device_name: Option<String>` parameter; routes to start_with_device
when set, falls back to auto-select start() when None or empty.
- src-tauri/src/commands/live.rs:StartLiveTranscriptionConfig — new
optional `microphone_device: Option<String>` field with same
semantics (rename_all = "camelCase" maps it to microphoneDevice on
the wire).
- src-tauri/src/commands/live.rs:run_live_session — picks
start_with_device when an explicit name is provided.
- src/lib/pages/DictationPage.svelte — passes
microphoneDevice: settings.microphoneDevice || null in the invoke.
Behaviour:
- "Auto" in the picker (empty string) -> backend auto-selects, skipping
monitor sources and validating by RMS energy.
- Specific device -> backend opens that device by exact name; if it has
been disconnected the user gets a clear error pointing them back at
Settings.
cargo check -p kon-audio passes clean. Tauri-crate cargo check requires
cmake (pre-existing infra dependency for whisper-rs-sys); install via
`sudo dnf install cmake clang-devel`.
Day 1 of the upgrade plan (output/reports/kon-upgrade-plan-2026-04-17.md
in the CORBEL workspace). Fixes the HANDOVER.md blocker: native live
transcription was capturing silence because PulseAudio/PipeWire monitor
sources (speaker loopback) were winning the device-selection race —
they deliver zero-valued bytes that satisfied the original
"any device that produces data within 350ms" check.
WHAT CHANGED:
crates/audio/src/capture.rs (rewrite):
- New `DeviceInfo` struct (serde-derived) for the Settings device picker
- New `MicrophoneCapture::list_devices()` enumerates inputs with metadata
- New `MicrophoneCapture::start_with_device(name)` for explicit selection
- Refactored `start()` with monitor-source filtering by name pattern
(.monitor suffix, "Monitor of " prefix, "loopback" substring) and
RMS-energy validation in a 350ms window
- Two-pass selection: real inputs first, monitor sources only as
last-resort fallback with explicit warning log
- Drop counter (Arc<AtomicU64>) tracks chunks dropped by `try_send`
failure under load — Codex review caught this as a silent-failure risk
- `dropped_chunks()` accessor for the live session
- Verbose tracing at every step for diagnosis
- Unit test for monitor-name detection
- `cargo check -p kon-audio` passes clean
crates/audio/src/lib.rs:
- Re-export `DeviceInfo`
crates/audio/Cargo.toml:
- Add serde dependency (for DeviceInfo derives)
src-tauri/src/commands/audio.rs:
- New `list_audio_devices` Tauri command (returns Vec<DeviceInfo>)
src-tauri/src/lib.rs:
- Register `list_audio_devices` in invoke_handler
src/lib/pages/SettingsPage.svelte:
- New "Audio" section with microphone picker dropdown
- Auto-populates on mount via `list_audio_devices`
- Refresh button + clear messaging about monitor sources
- Likely-monitor entries marked disabled in the dropdown
- Auto mode is the default (empty string in settings.microphoneDevice)
src/lib/stores/page.svelte.js:
- New `microphoneDevice` field in defaults (empty = auto-select)
NEXT STEPS (per the upgrade plan):
- Wire `microphoneDevice` from settings into `MicrophoneCapture::start_with_device`
in the live and standalone capture paths (currently both still call
the auto-selecting `start()`)
- Test on real hardware (Wayland + multiple input devices)
- Codex sanity-check of this diff is running in parallel; addendum to
follow if anything substantive comes back
Refs: /home/jake/Documents/CORBEL-Projects/kon/HANDOVER.md
output/reports/kon-upgrade-plan-2026-04-17.md (CORBEL workspace)
prevTaskIds was declared as $state, causing the $effect that writes to
it to re-trigger itself infinitely (effect_update_depth_exceeded). The
variable is just a comparison reference — doesn't need to be reactive.
Changed to a plain let.
Also removed debug event loggers and reverted grain z-index.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The invisible z-40 overlay was still eating pointer events across the
main content. Removed the backdrop — sidebar now floats without blocking
anything. Close with Escape key.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The full-screen backdrop (fixed inset-0 z-40) was eating all pointer
events, making the app unusable when the task sidebar was open. Replace
with a backdrop that only covers the content area (not titlebar) and
sits beside the sidebar rather than wrapping it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
WebKitGTK denies getUserMedia by default with no permission prompt.
Connect to the permission-request signal and auto-allow, plus enable
media-stream and media-capabilities in WebKit settings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The CTC model (onnx-community/parakeet-ctc-0.6b-ONNX) only has an encoder
— no decoder_joint or nemo128 preprocessor. transcribe-rs expects a TDT
transducer variant with all three. Switch to istupakov/parakeet-tdt-0.6b-v2-onnx
which has the correct int8 files.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upstream transcribe-rs 0.3.10 added required fields to TranscribeOptions.
Set both to None (use engine defaults).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add kon-hotkey crate with evdev-based global hotkey capture that works on
Wayland (and X11). Patterns from whisper-overlay: per-device async listeners,
inotify hotplug with udev permission retry, watch channel for live config
updates. Frontend detects Wayland at startup and selects evdev or
tauri-plugin-global-shortcut automatically.
Model downloads now support HTTP Range resume for interrupted downloads and
optional SHA256 integrity verification (incremental, no second pass).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:48 +01:00
495 changed files with 90201 additions and 4371 deletions
Thanks for filing a bug. Please attach a diagnostic bundle if possible — it speeds up triage by 10x. Generate one via Settings → Help → Generate diagnostic bundle.
- type:input
id:version
attributes:
label:Lumotia version
description:Settings → Help. e.g. v0.1.0
placeholder:v0.1.0
validations:
required:true
- type:dropdown
id:platform
attributes:
label:Platform
options:
- Linux (Fedora)
- Linux (Ubuntu LTS)
- Linux (other)
- macOS Apple Silicon
- macOS Intel
- Windows 11
- Windows 10
validations:
required:true
- type:textarea
id:what-happened
attributes:
label:What happened?
description:A clear description of the bug.
validations:
required:true
- type:textarea
id:expected
attributes:
label:What did you expect to happen?
validations:
required:true
- type:textarea
id:steps
attributes:
label:Steps to reproduce
placeholder:|
1. Open Lumotia
2. Click record
3. ...
validations:
required:true
- type:textarea
id:diagnostic-bundle
attributes:
label:Diagnostic bundle
description:Drag-and-drop the .zip from Settings → Help → Generate diagnostic bundle. Never includes audio or transcripts.
All notable user-facing changes to Lumotia are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/).
## [Unreleased]
## [0.1.0] - 2026-MM-DD <!-- replace with tag date on tag day -->
### Added
- **Dictation with on-device speech recognition.** Speak naturally; Lumotia transcribes locally using Whisper or Parakeet. Six Whisper variants (Tiny through Distil-Large v3) and Parakeet for lower-latency English transcription. The first-run hardware probe selects the fastest-accurate pair for your machine. Models download once and run entirely offline.
- **Live streaming transcription.** Audio is processed as you speak rather than in one batch at the end, so you see results as they arrive. Speech-gated chunking and a duplicate-boundary filter keep the output clean.
- **Transcript cleanup without touching your original.** A rule-based pass removes filler words, collapses repetition, and applies consistent spelling. If you have a local LLM model downloaded, it runs a second pass for natural-language polish. Your raw Whisper transcript is always preserved — cleanup is additive, never destructive.
- **Automatic task extraction with a safe fallback.** Lumotia identifies action items from your transcript using either the local LLM or a rule-based verb-list extractor. If the LLM fails, the fallback runs silently and tasks still appear.
- **MicroSteps and a 5-minute focus timer.** Any extracted task can be broken into 3–7 concrete sub-steps. Select a MicroStep to start a 5-minute timer scoped to that one step, so you can work on one thing at a time.
- **History with full-text search.** Every transcript is indexed and searchable. Filter by date, tag, or keyword. Open any past transcript in the editor to review or correct it; edits autosave.
- **Markdown export with YAML frontmatter.** Export any transcript as a Markdown file ready for Obsidian or any plain-text workflow. One button, native save dialog.
- **Read-only MCP server.** An optional `lumotia-mcp` binary lets Claude Desktop, Cline, Cursor, or any MCP-compatible client read your transcripts and tasks over a local stdio connection. It cannot write, edit, or delete anything.
- **First-run onboarding flow.** A short guided setup covers microphone selection, model download, and a practice recording with a pre-supplied prompt, so you know what to say.
- **Per-profile custom vocabulary.** Add domain-specific terms to a profile and Lumotia feeds them to the transcription engine as hints, reducing misrecognition of names and jargon.
- **Content tags and topic suggestions.** Lumotia suggests tags from your transcript. Promote them to your manual tag list with one click, or ignore them.
- **Keyboard-navigable throughout.** The full capture flow — record, review, extract tasks, start a timer, export — is completable without a mouse. Focus rings are visible on every interactive element. `prefers-reduced-motion` is respected app-wide.
### Privacy
- All transcription, cleanup, and task extraction runs on your device. No audio, transcript, or task data is sent anywhere.
- No telemetry, no analytics, no crash reports leave the machine unless you explicitly bundle one for a support request.
- An optional local activation log records when the hotkey fires. It never leaves your machine and can be disabled in Settings → Privacy.
- The optional MCP server is read-only and communicates over stdio only — no network listener, no remote access. Enabling it gives your MCP client read access to your full transcript history; treat it accordingly.
- Full disclosure: `docs/release/privacy-and-ai-use.md` (link added when that page lands).
### Known limitations
See `docs/release/v0.1-known-limitations.md` for the full list. Brief summary:
- **macOS App Nap** may pause Lumotia when its window loses focus during long sessions. The protection code is in place but not yet verified on Apple Silicon hardware.
- **Linux idle inhibit is not wired.** The compositor may lock or suspend during a long dictation session. Workaround: launch with `systemd-inhibit --what=idle:sleep lumotia` or raise your system screen-lock timeout.
- **Windows sleep prevention is not yet implemented.** Set your active power plan's sleep timeout to "Never" while dictating.
- **If the local LLM hangs mid-generation**, the status indicator may stay on "Cleaning up" until you restart the app. Your transcript is preserved and the rest of the app continues to work.
- Cloud transcription (OpenAI Whisper API and equivalents) is not available in this release.
- The MCP server grants read access to your entire transcript history with no per-row permission boundary. A finer-grained permission system is planned for a later release.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Lumotia — a local-first, cognitive-load-aware dictation + task-capture desktop app. Tauri 2 + Svelte 5 frontend, Rust workspace backend. Pre-alpha, daily-dogfooded on Linux/Wayland (KDE). Full product context lives in [README.md](README.md) and [docs/brief/](docs/brief/); design principles are non-negotiable and codified in [docs/whisper-ecosystem/lumotia-context.md](docs/whisper-ecosystem/lumotia-context.md).
## Commands
Dev launch (Vite + Tauri, with supply-chain pre-flight):
```bash
./run.sh # canonical; also: npm run dev:tauri
npm run dev:frontend # frontend-only iteration, no Tauri
```
Install: `npm ci --ignore-scripts` (never bare `npm install` — `--ignore-scripts` blocks the npm-worm postinstall vector).
Tests + checks:
```bash
cargo test --workspace # all Rust tests (220+ lib, 67 Tauri-app)
cargo test -p lumotia-transcription # single crate
cargo test -p lumotia-llm <test_name> # single test (substring match)
cargo check --workspace --all-targets # what CI runs
npm run test# vitest (jsdom; *.test.ts beside source)
npm run test -- src/lib/foo.test.ts # single vitest file
```
Release build: `npm run tauri build`. CI builds installers on tag push.
Rebrand-migration end-to-end probe (Linux only — Tauri 2 ignores `HOME` overrides on macOS):
```bash
cargo build -p lumotia
scripts/dogfood-rebrand-drill.sh # sandbox mode
scripts/dogfood-rebrand-drill.sh --against-real-home # refuses if lumotia data exists
```
## Architecture (the parts to internalise before editing)
Three layers, strict dependency direction: **Svelte (UI) → Tauri commands (OS bridge) → Rust crates (brain)**. The MCP server (`lumotia-mcp`) is a separate read-only binary that opens the same SQLite store — Lumotia-as-primitive for external agents.
Rust workspace (`crates/*` + `src-tauri/`) holds all logic. Tauri command modules in [src-tauri/src/commands/](src-tauri/src/commands/) are thin adapters and should not contain business logic. The 22 command modules map roughly 1:1 to a subsystem (audio, llm, transcription, profiles, tasks, hotkey, paste, windows, …). The utility-only modules in the same directory (`mod`, `power`, `security`) carry no `#[tauri::command]` attribute — don't add them to the invoke handler.
Engine abstractions: `LocalEngine` in `lumotia-transcription` wraps both Whisper (`whisper-rs`) and Parakeet (`transcribe-rs` ONNX) behind a common `Transcriber` trait. LLM surfaces (`cleanup_text`, `decompose_task`, `extract_tasks`) in `lumotia-llm` use GBNF grammars to guarantee parseable JSON. Prompt-injection-hardened cleanup prompt lives in `lumotia-ai-formatting::llm_client::CLEANUP_PROMPT`.
Storage is SQLite via `sqlx` 0.8 in `lumotia-storage`, with FTS5 for transcript search. The MCP binary opens this store read-only.
### Frontend state model
Svelte 5 runes (`$state`, `$derived`, `$effect`) — no Svelte 3/4 store API. State lives in [src/lib/stores/](src/lib/stores/), one file per store. The central store is [page.svelte.ts](src/lib/stores/page.svelte.ts) — transcripts, profiles, taskLists, templates, etc. are fields on it. Secondary windows (`/float`, `/viewer`, `/preview`) use named layouts (`+layout@.svelte`) to skip the main shell and run chrome-free.
### Wiring contracts (enforced socially, not by the compiler)
- **Every new Tauri command** must be (a) implemented in `src-tauri/src/commands/<module>.rs`, (b) registered in the invoke handler in [src-tauri/src/lib.rs](src-tauri/src/lib.rs), and (c) called from the frontend. Forgetting (b) is the most common breakage.
- **Every Settings-visible setting** needs a type field in [src/lib/types/app.ts](src/lib/types/app.ts) and a default in [src/lib/stores/page.svelte.ts](src/lib/stores/page.svelte.ts).
- **Every new workspace crate** needs a `description` in its `Cargo.toml`.
- Smoke test per new command or crate module; workspace floor is "no regressions on main."
## Platform notes that affect code
- **Wayland is a first-class target.** Don't assume X11. The preview overlay uses `WindowTypeHint::Utility`, never steals focus, is pinned across virtual desktops, and is hidden from Alt+Tab. The paste matrix (`wtype` / `xdotool` / `ydotool` on Linux, AppleScript on macOS, SendKeys on Windows) handles the focus-race against the overlay — see [src-tauri/src/commands/paste.rs](src-tauri/src/commands/paste.rs).
- **Linux hotkey is evdev**, not `tauri-plugin-global-shortcut`. Implemented in `lumotia-hotkey`. Requires the user to be in the `input` group; the crate surfaces this as a clear error when `/dev/input/event*` is inaccessible.
- **`run.sh` owns Linux rendering env vars** (`LIBCLANG_PATH`, `WEBKIT_DISABLE_DMABUF_RENDERER`, `GDK_BACKEND=x11` on Wayland to dodge a webkit2gtk issue). Anything that pre-checks these in `lib.rs` is checking what the launcher set, not the user's shell.
- **CI runs on all three OSes** (`.github/workflows/`) — Linux/macOS/Windows. macOS and Windows are CI-validated but not runtime-tested; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for tracked gaps (App Nap on Apple Silicon, idle-inhibit on X11/Linux, sleep prevention on Windows).
## Privacy invariants (non-negotiable)
No voice, transcript, or task data leaves the machine unless the user explicitly sends it. No telemetry, no analytics, no crash-reporting service. Cleanup is **additive** — raw Whisper transcript must always be recoverable. LLM scope is narrow: transcription cleanup + task extraction only. Not a wake-word agent, not a chat UI, not a multi-provider cloud fan-out. If a change risks any of this, surface it before implementing.
## v0.1 release scope (active gate)
The v0.1 ship gate lives in [docs/release/v0.1-checklist.md](docs/release/v0.1-checklist.md); the UI hardening boundary is pinned in [docs/release/v0.1-ui-hardening.md](docs/release/v0.1-ui-hardening.md). All release docs live under [docs/release/](docs/release/) — use that folder as the source of truth. Before adding a feature, check the **Out of scope for v0.1** list — reopening a v0.2-flagged item moves the ship date. Garden Inbox, full Settings 7-group regroup, OpenAI Whisper API BYOK, OEM verification, Obsidian plugin, and the Phase B filter-chain refactor are all explicitly v0.2+.
The release acceptance test is the 10-step tester flow (install → onboarding → record → cleanup → task → MicroSteps + timer → history search). Warm activation target: first real recording within 3 minutes of opening the app. UI hardening is a *hardening* pass, not a redesign — every item must be testable, not aesthetic.
## Where to look first
- Latest session context: [HANDOVER.md](HANDOVER.md), then dated handovers in [docs/handovers/](docs/handovers/).
- Product/strategy framing: [docs/brief/](docs/brief/) — especially `what-lumotia-is.md`, `design-principles.md`, `target-audience.md`.
- Active workstreams + 31-item research backlog: [docs/whisper-ecosystem/brief.md](docs/whisper-ecosystem/brief.md), `workstream-A.md`, `workstream-B.md`.
Lumotia is a single-developer-led, AI-assisted, human-directed indie app. Jake Sames at CORBEL defines the product, sets the privacy model, writes the tests that matter, and ships through a repeatable quality gate. AI coding tools are used during implementation. Every release is dogfood-tested, every release ships a known-limitations document, and every release has an audit trail in the git log. Read `docs/release/how-lumotia-is-built.md` to understand the trust model before contributing.
## What we are looking for in v0.1
Bug reports and tester feedback are the most valuable contributions right now. **New feature work is paused until v0.2** — the scope is locked per `docs/release/v0.1-checklist.md`. Reopening a v0.2-flagged item moves the ship date. If you have a feature idea, open a Discussion rather than a PR.
## How to file a bug
Open an issue using the **Bug report** template. The template walks you through version, platform, steps to reproduce, and expected vs actual behaviour.
Please attach a diagnostic bundle — it speeds up triage significantly. Generate one from **Settings → Help → Generate diagnostic bundle**. The bundle never includes audio or transcript content; it contains logs, system info, and redacted preferences only.
## How to file v0.1 tester feedback
Open an issue using the **v0.1 Tester feedback** template. It covers the structured questions from the tester-onboarding-kit: cold-setup pass, warm-activation pass, confusion points, breakage, task-extraction quality, and whether you would use it again.
If you followed the onboarding kit, you already have the answers. The template takes about 5 minutes to fill in.
## How to submit a PR
Small, focused changes only. The PR template has a checklist — run every gate green before submitting.
-`scripts/dogfood-rebrand-drill.sh` 8/8 (if you touched migration or data-directory logic)
- Manual UI walk-through (if you touched the frontend)
**Architectural changes require a Discussion first.** Opening a PR that restructures crates, reorganises commands, or changes the storage schema without prior alignment wastes both our time.
## Local dev setup
```bash
npm ci --ignore-scripts # never bare npm install — --ignore-scripts blocks postinstall vectors
./run.sh # canonical dev launch (Vite + Tauri, with supply-chain pre-flight)
npm run dev:frontend # frontend-only iteration, no Tauri
```
Per-platform dependencies (WebKit, LLVM, Rust toolchain, evdev headers) are documented in `docs/dev-setup.md`. The Rust toolchain is pinned in `rust-toolchain.toml` — you do not need to manage it manually.
## Code of conduct
This is a calm, professional project. No harassment, no personal attacks, no bad-faith contributions. The maintainer is one person — please be patient on response times. Issues and PRs that are rude or dismissive will be closed without comment.
## Licence
Lumotia is licensed under **AGPL-3.0-or-later**. By submitting a pull request, you agree that your contribution is offered under the same licence. If that does not work for you, please say so before putting in the work.
> **Note:** Session-specific handover. Migration v14 (`transcripts.llm_tags`) was the head **at the time of this session**. Subsequent work has advanced the schema; current head is v15 (`idx_transcripts_profile_created`, composite index). For the current codebase shape, see [`docs/architecture-map/`](docs/architecture-map/) and [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md).
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 → Lumotia** completed 2026/05/13 (cascade across both repos, all 15 phases, cynical-QC gated). Codebase paths, package names, crate names, bundle identifier (`consulting.corbel.lumotia`), localStorage keys, settings keys, event channels, and on-disk data dir all carry `lumotia`. Migration shims preserve existing user data (data-dir rename, settings-key rename, localStorage-key rename). Remote repo rename pending Jake's action. See `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_lumotia_rebrand_cascade_complete.md` (write pending Phase 15.5 of the cascade plan).
## What shipped this session
### 9a — Export plumbing
-`write_text_file_cmd` Rust command in new `src-tauri/src/commands/fs.rs`, with two unit tests (UTF-8 round-trip + bad-parent error path). Registered in `invoke_handler!`. `tempfile = "3"` added as `[dev-dependencies]` on the lumotia crate.
- HistoryPage `exportMarkdown` no longer copies to clipboard; it opens the OS save dialog and writes the file. Cancel returns silently.
- HistoryPage gained a slim leading checkbox per row, a bulk-action toolbar (select-all / clear / export / delete), `Esc` to clear, `Cmd/Ctrl+A` to select-all-visible when focus is inside the list and not in a text input.
### 9b — LLM content tags
-`lumotia-llm` exports a new `ContentTags { topic, intent }`, an `INTENT_CLOSED_SET`, an `is_valid_intent` helper, a `CONTENT_TAGS_SYSTEM` prompt and a `CONTENT_TAGS_GRAMMAR` GBNF (recursive style matching the existing `TASK_ARRAY_GRAMMAR`).
-`LlmEngine::extract_content_tags` method follows the same render-chat → generate → JSON-parse shape as the existing `cleanup_text` and `extract_tasks`. Truncates to the trailing 2000 chars on a UTF-8 boundary; max_tokens 96 is enough for the JSON envelope. Smoke test in `crates/llm/tests/content_tags_smoke.rs` is gated on `LUMOTIA_LLM_TEST_MODEL` matching the Phase 8 pattern.
-`extract_content_tags_cmd` Tauri wrapper bridges through `state.llm_engine` with the standard `spawn_blocking` + `PowerAssertion` guard.
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 ''`.
-`lumotia-storage``database.rs` SELECT statements include the column. `TranscriptRow` + `transcript_row_from` carry it. `update_transcript_meta` accepts an `Option<&str>` for `llm_tags` (sixth optional, `#[allow(too_many_arguments)]` keeps clippy happy without inverting the signature into a struct).
-`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.
### 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.
- 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.
### 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.
-`cargo test`: **277 tests pass**, 0 failed. Storage gained 1 new test (`update_transcript_meta_writes_llm_tags`), lumotia-tauri gained 2 (write_text_file). The Phase 8 brittle test fix is in this count.
-`npm run check`: 0 errors, 0 warnings across 3957 files.
-`npm run build`: clean production build via `@sveltejs/adapter-static`.
## Plan correction summary (for any future reader)
The original Phase 9 spec + plan committed at `49a795f` + `48d3db7` had three mismatches against the actual codebase, surfaced by a critical-review pass before execution. Layered as a corrections appendix in commit `3eb24f2`:
1.`lumotia-llm` is `LlmEngine::generate(prompt, config)` synchronous, not the speculated `LlamaEngine::generate_chat(messages, config).await`.
2.`AppState.llm_engine: Arc<LlmEngine>` is direct, not behind a `RwLock`.
3.**Structural** — `transcripts.llm_tags` requires a real SQLite migration plus Tauri command extension because the frontend `saveHistory()` is a no-op stub. Original plan assumed `manualTags`-mirroring would suffice. Migration v14 + `update_transcript_meta` extension landed as a new task to cover this. Picked up the latent `manualTags` persistence bug for free.
## Owed to Jake (next session)
1.**Manual dogfood walkthrough.** Cannot be driven by an automated agent. When opening Lumotia next:
- Export one transcript via the History "Export .md" button — save dialog opens, file written to chosen path. Cancel — no toast, no fallback.
- Select 3 history rows via checkboxes — toolbar surfaces, "Export selected" writes one .md per row to a chosen folder, collisions suffixed " (2)" etc.
- Click "Tag" on one row — within a few seconds, dashed `topic:*` and `intent:*` chips appear. Click a chip — it moves into `manualTags` (solid accent chip). Page refresh — both `manualTags` and `llmTags` survive (this is the persistence-fix outcome).
- "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.
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.
Tracked limitations and partial implementations in the current codebase. Each entry points at the code that owns the gap so future work can find it.
## Power management
### KI-01 — macOS App Nap guard pending Apple Silicon runtime verification
**Status:** macOS code path compiles and the registry tests pass, but runtime behaviour against actual OS idle-throttling has not been confirmed on Apple Silicon hardware. Also tracked internally as **RB-08**.
**Impact:** Long live-dictation sessions on macOS may still be slowed or paused by the OS until verified end-to-end on hardware.
**Workaround:** Keep the app window focused, or move the cursor periodically. For testing, App Nap can be disabled globally with `defaults write NSGlobalDomain NSAppSleepDisabled -bool YES` (revert with `-bool NO`).
### KI-02 — Linux idle inhibit ✓ fixed in v0.1
**Status:** Resolved. `acquire_idle_inhibit` now calls `org.freedesktop.login1.Manager.Inhibit`
via zbus (blocking, offloaded to `spawn_blocking`) on recording start, holding the returned file
descriptor. `release_idle_inhibit` closes the fd on recording stop, which atomically releases the
lock. The inhibit scope is `idle:sleep:handle-lid-switch` in `block` mode.
If D-Bus is unavailable (non-systemd containers, exotic distros), the call fails gracefully with a
`tracing::warn!` and recording continues — the workaround below remains valid in those edge cases.
**Workaround (edge cases only):** If the power plan has a policy override that blocks `SetThreadExecutionState`, set the active sleep timeout to "Never" while dictating.
## Cloud providers
### KI-04 — `lumotia-cloud-providers` crate is not user-exposed
**Status:** The crate is a declared workspace dependency in [`src-tauri/Cargo.toml:36`](src-tauri/Cargo.toml#L36) and compiles into the binary, but no Tauri command, page, or settings field invokes `store_api_key` / `retrieve_api_key`. There is no UI to enter or store a cloud API key in the current build.
**Source:** [`crates/cloud-providers/src/keystore.rs`](crates/cloud-providers/src/keystore.rs). The `TODO` in the doc comment captures the planned migration to OS-native credential storage (the `keyring` crate or equivalent) before the feature is wired up.
**Impact:** None for end users today (no exposed surface). When the feature is wired post-launch, cross-restart persistence must land before any "save key" UX ships, otherwise saved keys reset on every restart.
**Workaround:** N/A.
## Frontend
### KI-05 — Dual theme system: `settings.theme` and `preferences.theme` coexist
**Status:** Theme is stored twice. The legacy `settings.theme` ("Dark" / "Light" / "System", localStorage-only) is bound to two `SegmentedButton` controls in `SettingsPage.svelte`. The canonical `preferences.theme` ("dark" / "light" / "system", Tauri-persisted via `save_preferences`) is what the DOM and runtime actually consume. A migration `$effect` in `src/routes/+layout.svelte` (and the secondary `+layout@.svelte` files for `/preview`, `/viewer`, `/float`) syncs `settings.theme` → `preferences.theme` whenever the legacy value changes.
- [`src/lib/pages/SettingsPage.svelte:1118`](src/lib/pages/SettingsPage.svelte#L1118), [`:2360`](src/lib/pages/SettingsPage.svelte#L2360) (UI bindings still on the legacy field)
**Impact:** No user-facing bug; theme switching works. The cost is code complexity and a small race window where preference fan-out can lag behind a `settings.theme` change. The `$effect` is lightweight (string compare plus a debounced persist when the mapped value differs), not a hot-loop performance problem despite earlier framing.
**Resolution (deferred):** Switch the two `SettingsPage` bindings to `preferences.theme` with mapped options, retire the migration `$effect` in all four route layouts, drop `theme` from `SettingsState`, and add a one-shot localStorage migration that copies any historical `settings.theme` into `preferences` on first run after the cleanup. Touches ~5 files including the 2 484-LOC `SettingsPage.svelte`; deferred from the v0.1 polish pass to avoid a moderate-risk frontend change immediately before launch.
**Workaround:** N/A.
## Engine architecture (Phase A)
### KI-06 — `transcribe_file` and live-transcription commands not yet rewired through `Orchestrator`
**Status:** Phase A introduced the `TranscriptionProvider` async trait ([`crates/cloud-providers/src/provider.rs`](crates/cloud-providers/src/provider.rs)), `EngineRegistry` ([`crates/transcription/src/registry.rs`](crates/transcription/src/registry.rs)), and `Orchestrator` plus `LocalProviderAdapter` ([`crates/transcription/src/orchestrator.rs`](crates/transcription/src/orchestrator.rs)). The abstractions compile, are object-safe, and pass unit tests against a mock provider. The existing dictation path ([`src-tauri/src/commands/transcription.rs`](src-tauri/src/commands/transcription.rs)) still calls `LocalEngine::transcribe_sync` directly via `pick_engine`, not through the orchestrator. Live and meeting commands likewise route around the orchestrator.
**Impact:** None at runtime. The orchestrator path is dormant until a follow-up commit migrates the call sites. Cloud providers (Phase G) cannot be exercised end-to-end until this rewire lands.
**Resolution (deferred):** Phase A.1 follow-up commit. Build an `EngineRegistry` at app boot (one `LocalProviderAdapter` per `LocalEngine`), inject `Arc<Orchestrator>` into `AppState`, replace `pick_engine` plus `engine.transcribe_sync` chunking-loop calls with `orchestrator.transcribe(audio, &profile)` per chunk. Keep the chunking strategy in the command layer (it is a strategy concern, not a dispatch concern). Tests should cover both paths against a mock provider before touching the real engines.
**Workaround:** N/A. Existing path works as before.
## How to add an entry
When shipping a partial implementation or known limitation, add a `KI-NN` entry here with the four standard fields: **Status**, **Source** (file:line), **Impact**, **Workaround**. Link from the affected module's doc comment back to this file by ID.
Lumotia is a local-first, cognitive-load-aware dictation and task-capture desktop app. Every transcription, LLM cleanup, and task extraction runs on the user's machine. No telemetry, no analytics, no cloud dependency. The app is designed around a single observation: people who think in bursts lose ideas faster than they can type, and the tool's job is to get out of the way.
---
## Status
**Status: v0.1 release candidate.** See [docs/release/](docs/release/) for the ship checklist + known limitations. Actively dogfooded on Linux (KDE Plasma 6 on Wayland). macOS and Windows targets are in scope and exercised by CI, but not yet beta-ready.
- Current `main`: see commit log
- 9 library crates plus the Tauri app crate; 220+ lib tests plus 67 Tauri-app tests, all passing
- Cross-platform CI (Linux / macOS / Windows) via GitHub Actions
- Tracked limitations live in [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md)
---
## Design principles (non-negotiable)
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.** Lumotia is a dictation primitive: via MCP, CLI, and filesystem export, it slots into whatever workflow the user already has (Obsidian, Claude Desktop, Cline, any text field).
4.**LLM scope is narrow.** The in-app LLM does transcription cleanup and task extraction. It is not a wake-word agent, not a chat UI, not a multi-provider cloud fan-out.
5.**Raw transcript is always recoverable.** Cleanup is additive, never destructive. The user can always see and revert to what Whisper heard.
These are enforced in the codebase (where practical) and in the docs under [`docs/whisper-ecosystem/lumotia-context.md`](docs/whisper-ecosystem/lumotia-context.md).
---
## What Lumotia 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.
- Vulkan / CUDA-accelerated **Parakeet** inference via sherpa-onnx (NVIDIA's English-only model; lower latency than Whisper-Large on English).
- **Parakeet-as-default for English** when hardware supports it; first-run hardware probe picks the fastest-accurate pair.
- **Resumable downloads with SHA-256 verification**; retains audio if transcription fails.
- **Per-profile custom vocabulary** fed to Whisper as `initial_prompt` plus to the LLM cleanup prompt; bulk import via paste.
- **Live streaming transcription** with speech-gated chunking, hallucination filtering, and duplicate-boundary detection.
### LLM formatting (local only)
- Local LLM runtime via [llama-cpp-2](https://github.com/utilityai/llama-cpp-rs) 0.1.144 with Vulkan.
- 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.
### Task capture
- Automatic task extraction from any transcript.
- **MicroSteps** — one-tap "break this task into 3–7 concrete physical actions."
- Profile-scoped task lists with inbox / today / soon / later buckets.
- Tasks back-link to their source transcript.
### Input, paste, and window management
- **Global hotkey** — evdev-based on Linux (Wayland-compatible out of the box), `tauri-plugin-global-shortcut` on macOS / Windows. Per-OS capability matrix rejects invalid key combinations.
- **Platform-aware paste matrix** — `wtype` / `xdotool` / `ydotool` on Linux, AppleScript on macOS, SendKeys on Windows. Clipboard snapshot + 300 ms restore after paste.
- **Wayland-hardened transcription preview overlay** (`/preview`): pinned across virtual desktops, hidden from Alt+Tab via `WindowTypeHint::Utility`, never steals focus, focus-gated open.
- **Meeting auto-capture** (opt-in, default off): single-signal process-list watcher, user-editable app list, surfaces a non-modal reminder. No mic-activity heuristics, no calendar integration.
### History and search
- **FTS5-indexed transcript search** over SQLite.
- **YAML-frontmatter markdown export** one-click into Obsidian vault.
- Transcript editor window (`/viewer`) with debounced autosave.
### External integration
- **MCP stdio server** (`Lumotia-mcp`) exposing read-only transcripts and tasks to any Model Context Protocol client (Claude Desktop, Cline, Cursor, etc.). No authentication, read-only, local-only.
The Rust workspace is the brain; Tauri is the OS integration surface; Svelte is the UI. The MCP server (`Lumotia-mcp`) is a separate binary that opens Lumotia's SQLite store read-only — it's Lumotia-as-primitive for external agents.
| **`Lumotia-transcription`** | `whisper-rs` backend (`WhisperRsBackend`) that owns a `WhisperContext` and supports `set_initial_prompt`. `LocalEngine` wraps both Whisper and Parakeet (via `transcribe-rs` ONNX) behind a common `Transcriber` trait. Streaming primitives (`VadChunker`, `LocalAgreement`, buffer trim) live in the `streaming/` module. Model manager handles downloads, paths, and disk checks. |
| **`Lumotia-llm`** | `llama-cpp-2` engine with a four-tier Qwen3.5 / Qwen3.6 model manager. Three high-level surfaces: `cleanup_text` (formatting), `decompose_task` (3–7 micro-steps, GBNF-constrained JSON array), `extract_tasks` (optional-array, GBNF-constrained). Resumable HTTP downloads with SHA-256 verify. |
| **`Lumotia-ai-formatting`** | Post-processing pipeline: filler removal, British English conversion, anti-hallucination filter, smart paragraph breaks on long pauses, optional LLM cleanup. Also hosts the `llm_client::CLEANUP_PROMPT` constant (prompt-injection-hardened). |
| **`Lumotia-hotkey`** | Linux `evdev` hotkey listener with device hotplug. Parses Tauri-style hotkey strings (`Ctrl+Shift+R`), emits Pressed / Released events. Works natively on Wayland (no X11 dependency). Checks `/dev/input/event*` access on startup; surfaces a clear "add yourself to the `input` group" error when missing. |
| **`Lumotia-cloud-providers`** | BYOK cloud-STT provider stubs. Currently empty scaffolding. When populated: OpenAI-compatible endpoint + Anthropic (ceiling for scope). |
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).
| Audio capture | [`cpal`](https://github.com/RustAudio/cpal) | current |
| Resampling | [`rubato`](https://github.com/HEnquist/rubato) | current |
| File decode | [`symphonia`](https://github.com/pdeljanov/Symphonia) | current |
---
## Platform support
| Platform | Status | Notes |
|---|---|---|
| Linux Wayland (KDE Plasma, GNOME Mutter, Hyprland, Sway) | **Primary target**, daily-dogfooded on KDE | evdev hotkey, GTK 3 via webkit2gtk, Vulkan, all paste backends; idle inhibit not wired (see KI-02) |
| Linux X11 | Supported | xdotool paste path, GTK 3; idle inhibit not wired (see KI-02) |
| macOS | In CI, untested runtime | osascript paste, Metal via MoltenVK, App Nap guard pending Apple Silicon verification (see KI-01) |
| Windows | In CI, untested runtime | SendKeys paste, Vulkan-first GPU path, bundled DLLs for CPU fallback; sleep prevention not wired (see KI-03) |
CI runs `cargo check --workspace --all-targets` + `svelte-check` on all three on every push and PR.
---
## Build + development
### Prerequisites
Linux (Fedora/RHEL listed; adjust for your distro):
See [`docs/dev-setup.md`](docs/dev-setup.md) for the authoritative per-platform dependency list and for how `LIBCLANG_PATH` should be set.
### Installing npm dependencies
Use `npm ci --ignore-scripts` rather than bare `npm install`. `--ignore-scripts` blocks the postinstall script vector that npm-worm attacks (Shai-Hulud, mini-Shai-Hulud) rely on. `ci` installs strictly from `package-lock.json`, refusing to mutate the lockfile silently.
```bash
npm ci --ignore-scripts
```
`run.sh` runs `npm audit signatures` automatically whenever `package-lock.json` is newer than the last successful audit, and refuses to launch on signature mismatch. Skip with `LUMOTIA_SKIP_AUDIT=1` for offline dev.
### Dev launch
Canonical full-stack dev launch — starts Vite, waits for port 1420, then launches Tauri:
- [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md) — tracked partial implementations and limitations
---
## Roadmap
The shipped code represents Phases 1–3 and a partial Phase 4.
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 `Lumotia-cloud-providers` grows a real provider
- **`ggml` dedup** — replace the interim `-Wl,--allow-multiple-definition` link flag with a proper shared-lib setup; unblocks custom shader / backend work
- **Mobile (iOS / Android)** — long-horizon, gated on the single-binary Rust stack scaling
Explicitly shelved (not coming without specific community signal):
- Second notes-editing surface (transcripts leave Lumotia via frontmatter to Obsidian)
- Speaker diarization
- Dragon-style passage-based speaker fine-tuning (Whisper has no speaker adaptation)
---
## Contributing
Pre-alpha status; contribution process TBD before public beta. For now:
- Every Tauri command change must register in both [`src-tauri/src/lib.rs`](src-tauri/src/lib.rs) (invoke handler) and in the invoking frontend code.
- Every Settings-visible setting must have a type field in [`src/lib/types/app.ts`](src/lib/types/app.ts) and a default in [`src/lib/stores/page.svelte.ts`](src/lib/stores/page.svelte.ts).
- Every new workspace crate needs a `description` in its `Cargo.toml`.
- Tests: add at least a smoke test per new Tauri command or crate module. The workspace test floor is "no regressions on main."
- Wayland compatibility is a first-class concern — don't assume X11. The preview overlay and paste matrix live-document what this looks like in practice.
---
## Licence
AGPL-3.0-or-later. See [LICENSE](LICENSE) for the full text. The implementation is AI-assisted; the trust + audit framing is in [docs/release/how-lumotia-is-built.md](docs/release/how-lumotia-is-built.md).
---
## Reporting issues
File issues at https://github.com/jakeadriansames/lumotia/issues — please include your platform, the Lumotia version (Settings → About), what you did, what you expected, and what actually happened. Crash dumps live at `<app-data-dir>/crashes/`; attaching the most recent one helps a lot.
> **Where you are:** [Architecture map](../README.md) → Frontend
**Plain English summary.** This slice is everything the user sees. It is a Svelte 5 single page app, served by SvelteKit in SPA mode, hosted inside Tauri's webview. It owns four windows (main, tasks float, transcript viewer, transcription preview overlay), seven page modules switched by an in memory router store, around thirty reusable components, ten reactive stores, and the design system tokens. The frontend never touches the filesystem, audio, models or the LLM directly. Everything that crosses the WebView boundary goes through `invoke()` (calling Rust commands) or Tauri events. If you delete this slice, you delete the entire user surface but keep the engine.
- **Frameworks:** Svelte 5 (runes mode), SvelteKit 2.58, `@sveltejs/adapter-static` with `index.html` fallback (SPA), Vite 6, Tailwind v4 via `@tailwindcss/vite`, svelte-i18n 4, lucide-svelte for icons.
- **Tauri SDK touchpoints:** `@tauri-apps/api` (core invoke, event, window) plus `plugin-autostart`, `plugin-dialog`, `plugin-global-shortcut`, `plugin-notification`, `plugin-opener`. SSR is disabled (`src/routes/+layout.js`) so the whole tree is client side only.
- **Persistence used directly by frontend:** `localStorage` for `lumotia_settings`, `lumotia_profiles`, `lumotia_task_lists`, `lumotia_templates`, `lumotia_locale`, plus a small handoff key for the viewer window. Preferences additionally persist via Tauri (`save_preferences`).
- **Build commands:** `npm run dev` (`svelte-kit sync && vite dev`), `npm run build`, `npm run check` (svelte-check using `jsconfig.json`).
## Map of this slice
- [Windows and routes](windows-and-routes.md). The four Tauri windows, the SvelteKit routes that back them, the `+layout@.svelte` break, and how the shell decides between custom chrome and native decorations.
- [Pages overview](pages-overview.md). Index of the seven pages routed by `page.current`, and the `/float`, `/viewer`, `/preview` route pages.
- [Pages: dictation](pages/dictation.md). The hero recording surface.
- [Pages: settings](pages/settings.md). The 2 484 line settings panel.
- [Pages: tasks](pages/tasks.md). Inbox, today, soon, later board.
- [Pages: files](pages/files.md). Drag and drop file transcription.
- [Pages: first run](pages/first-run.md). Hardware probe and model bootstrap.
- [Components](components.md). All 25 reusable components grouped by role.
- [Stores](stores.md). Ten Svelte 5 `$state` stores, plus what each owns and what events they react to.
- [Actions, utils, types](actions-utils-types.md). The single Svelte action, the 16 utility modules, and the `app.ts` type bible.
- [Internationalisation](i18n.md). svelte-i18n setup, locale persistence, current coverage.
- [Design system](design-system.md). Reference material under `src/design-system/` (preview HTML, JSX UI kits). Note: this is reference, not live code.
- [Frontend ↔ Tauri bridge](frontend-tauri-bridge.md). Every `invoke()` command name and every event listened for or emitted, with their callers.
**Out (frontend triggers behaviour back into the runtime).**
- DOM `CustomEvent` bus on `window` carries internal traffic (`lumotia:start-timer`, `lumotia:toggle-recording`, `lumotia:task-completed`, etc). The Rust side does not listen to these; they are intra frontend.
- Tauri `emit()` is used for `lumotia:preferences-changed` only, to fan preference updates across windows.
- Multi window orchestration: pages call `open_task_window`, `open_viewer_window`, `open_preview_window` to ask Rust to spawn secondary webviews.
**Other slices that read frontend conventions.**
- Slice 02 (Tauri runtime) emits the events listed above and registers commands the frontend invokes.
- Slice 03 (audio + transcription) ships partial results via a `Channel` (Tauri 2 typed channel) opened in `DictationPage`.
- Slice 04 (LLM, formatting, MCP) emits `lumotia:llm-download-progress` and `cleanup_transcript_text_cmd` results consumed by Dictation.
- Slice 05 (storage, hotkey, build) supplies `add_transcript`, `delete_transcript`, profiles, tasks and the evdev hotkey backend.
## Existing in repo docs (do not duplicate)
-`docs/brief/` and `docs/whisper-ecosystem/brief.md`. Product brief and feature set.
-`docs/code-review-2026-04-22.md`. Last code review snapshot.
-`docs/handovers/`. Ship logs (e.g. Phase 9c is the most recent settings related one).
-`docs/audit/`. UX and accessibility audits.
-`docs/superpowers/`. Process artefacts.
-`src/design-system/README.md`, `src/design-system/SKILL.md`. Brand and design ground truth.
This slice index is the navigation hub. The map files referenced above carry the detail.
## Open questions, debt, drift, dead code
1.**`src/lib/Sidebar.svelte` lives outside `components/`.** Every other reusable Svelte module is under `src/lib/components/`. The sidebar is the only sibling of those folders. Cosmetic but it surprises contributors.
2.**Two parallel theme systems.**`settings.theme` (string `"Light"`/`"Dark"`/`"System"`) coexists with `preferences.theme` (`"light"`/`"dark"`/`"system"`). `+layout.svelte:61-68` and `float/+layout@.svelte` both run a "legacy → new" migration `$effect` every time. The legacy pathway should be retired.
3.**`@ts-nocheck` is widespread.** `+layout.svelte`, `DictationPage.svelte`, `FilesPage.svelte`, `FirstRunPage.svelte` and the float/viewer/preview layouts all opt out of TypeScript. Type safety stops at the page boundary.
4.**`SettingsPage.svelte` is 2 484 lines.** Phase 9c handover already flagged this. `SettingsGroup.svelte` exists but the deeper restructure into seven progressive disclosure groups plus search has been deferred.
5.**`profiles` redirect is a dead route.** `+page.svelte:13` still rewrites `page.current === "profiles"` to `"settings"`, suggesting the old profiles page was removed but call sites may persist. Worth grepping and deleting the redirect after a release.
6.**Cross window settings sync uses `localStorage` `storage` events.**`float/+layout@.svelte` listens for `storage` to apply settings, while preferences use the dedicated `lumotia:preferences-changed` Tauri event. Inconsistent. Settings sync via `storage` is fragile because it only fires on cross document writes.
7.**`shims.d.ts` next to the lib root.** Single shim file at `src/lib/shims.d.ts`. Worth verifying it is still needed (Svelte 5 + SvelteKit ship most ambient types now).
8.**`design-system/ui_kits/`** contains JSX components and an HTML index. They are reference, not live, and should not import from the runtime tree. Confirm the build excludes them.
9.**`speaker.svelte.ts` is ten lines.** A near empty store. Used by `SpeakerButton.svelte`. Consider folding into a util if it never grows.
10.**CSS variable `--font-size-transcript` is set on `body` from `+layout.svelte` but `preferences` already sets `--font-size-body` and `--transcript-font-size`.** Three knobs for transcript text size in different stores. Drift between `settings.fontSize` and `preferences.accessibility.transcriptSize` is likely.
11.**i18n coverage is thin.** Each locale has 19 lines. svelte-i18n is wired but most strings remain hardcoded. Treat as a stub.
12.**Tailwind v4 has no standalone config file.** All theming lives in `app.css``@theme`. There is no `tailwind.config.{js,ts}`. Mention this so contributors do not waste time looking.
13.**`docs/architecture-map/01-frontend/` was empty before this pass.** Reciprocal slice indexes (02 to 05) need updating to point here.
**Plain English summary.** Helpers that are not components and not state. The single Svelte action (`bionicReading`), 16 utility modules under `src/lib/utils/`, and the `app.ts` type module that everyone imports.
## Actions
### `src/lib/actions/bionicReading.ts` (74 LOC)
Svelte action that toggles "bionic reading" rendering on a node. Walks the text nodes via `TreeWalker`, replaces them with bolded prefix + plain suffix spans. A `MutationObserver` reapplies on text changes (disconnects while mutating to avoid infinite loop). `stripBionic` undoes by unwrapping `<b>` tags and normalising adjacent text nodes.
Used in DictationPage and the viewer. Wired to `preferences.accessibility.bionicReading`.
## Utils
### `runtime.ts` (45 LOC)
Exports `hasTauriRuntime()`. Probes for `window.__TAURI_INTERNALS__` or `window.isTauri` to short circuit Tauri calls in browser preview mode. Used everywhere as the gate before `invoke()`.
### `osInfo.ts` (110 LOC)
Caches OS info via `invoke("os_info")` (or similar; check `lib.rs`). Exposes `loadOsInfo`, `isMac`, `isLinux`, `modKeyLabel` synchronously after `loadOsInfo` resolves. Drives whether the shell renders custom or native chrome.
### `errors.ts` (8 LOC)
`errorMessage(e)` returns a string from `Error | unknown`. Tiny.
### `storage.ts` (8 LOC)
`parseStoredJson<T>(key, fallback)` wraps `JSON.parse(localStorage.getItem(key))` with try/catch. Used by `settingsMigrations`, `page.svelte.ts`, and the viewer handoff.
### `settingsMigrations.ts` (134 LOC)
Versioned migrations for `lumotia_settings`. Holds `CURRENT_SETTINGS_VERSION = 1`. Envelope shape: `{ version: number, data: T }`. Reads bare unversioned blobs as v0. `loadSettingsWithMigration(key, defaults)` walks the chain and toasts on corruption.
### `frontmatter.ts` (148 LOC)
Builds YAML frontmatter for transcript markdown export.
-`deriveAutoTags(text, profile)`. Heuristic auto tags.
-`buildFrontmatter(transcript)`. Returns the YAML block.
Save dialog + write file dance. `suggestedFilename(item)` builds `<slug>-<YYYY-MM-DD>.md`. `saveTranscriptAsMarkdown(item)` opens `@tauri-apps/plugin-dialog`, then writes via `invoke`. `exportTranscriptsToDir(items)` for bulk export.
### `export.ts` (125 LOC)
Generic export helpers used by DictationPage and FilesPage. Format choice: plain text, markdown, JSON. Falls through to clipboard or save dialog.
### `taskExtractor.ts` (224 LOC)
`extractTasks(text)` uses regex heuristics for "TODO:", "Action:", "I need to ...", numbered lists, etc. Falls back when LLM extraction is unavailable.
### `textMeasure.ts` (166 LOC)
Canvas-based pre-wrap text measurement. `measurePreWrap(text, font, lineHeight, width)` returns the rendered height. `clampTextLines(text, n)` trims to N lines plus ellipsis. Used by HistoryPage virtual scroll and DictationPage textarea sizing.
### `accessibilityTypography.ts` (41 LOC)
Reads `--font-family-body`, `--font-size-body`, `--line-height-body`, `--letter-spacing-body` from `<html>` and returns numeric values. `pretextFontShorthand`, `bodyPretextLineHeight`, `transcriptPretextFont`, `transcriptPretextLineHeight` are the public helpers.
### `virtualList.ts` (49 LOC)
`buildCumulativeOffsets(heights)` and `findVisibleRange(offsets, scrollTop, viewportHeight, overscan)`. Pure. Used by HistoryPage and `VirtualSegmentList`.
Loads the start/stop/complete cue sounds via `<audio>` and exposes `playStartCue()`, `playStopCue()`, `playCompleteCue()`. Volume from `settings.soundCueVolume`. Honours `settings.soundCues` flag.
### `hotkeyValidity.ts` (149 LOC)
Validates a hotkey combo against per-OS rules (no Cmd alone on macOS, no single letters, etc). Used by `HotkeyRecorder.svelte`.
Single ambient declaration file. Worth verifying the contents are still needed (Svelte 5 + SvelteKit ship most ambient types now). README debt note 7.
## Watch outs
-`constants.js` is JS, not TS. If you migrate, the JSDoc shapes in `BUCKET_COLORS` need to align with Tailwind class strings; type-only objects still need a runtime export.
-`taskExtractor.ts` is a regex heuristic. The LLM path (`extract_tasks_from_transcript_cmd`) is the better signal; the heuristic is the offline fallback.
-`osInfo.ts` caches forever. If you need to handle a hot OS detection retry (you should not), invalidate the cache yourself.
-`textMeasure.ts` uses an offscreen `<canvas>`. Costs are real for long transcripts; results are cached per (text, font, line-height, width) tuple in HistoryPage.
-`frontmatter.ts` and `saveMarkdown.ts` overlap. The former produces the body; the latter handles the I/O. Keep them split.
## See also
- [Stores](stores.md). Many utils are imported by stores.
- [Components](components.md). The components that consume these helpers.
- [App shell and styling](app-shell-and-styling.md). CSS variables consumed by the typography utils.
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → App shell and styling
**Plain English summary.** Where the visual chrome and CSS plumbing live. `app.html` is the SvelteKit document. `app.css` carries the Tailwind v4 import, the `@theme` token block, the brand `@font-face` declarations, the sensory zones, and the accessibility CSS variables. There is no separate `tailwind.config.{js,ts}`; Tailwind v4 is configured entirely in `app.css`. Fonts ship from `src/fonts/` (bundled by Vite) and `static/fonts/` (served as-is).
- **Frameworks:** Tailwind v4 via `@tailwindcss/vite`. No PostCSS config. No standalone Tailwind config file.
- **Adapter:** `@sveltejs/adapter-static` with `index.html` fallback (SPA, `svelte.config.js`).
## What's in here
### `src/app.html` (13 LOC)
Standard SvelteKit document. Sets `lang="en"`, charset, viewport, favicon, title (`Lumotia`), and applies `data-sveltekit-preload-data="hover"` on `<body>`. The body content is wrapped in `<div style="display: contents">` so the SvelteKit-injected children render inline.
### `src/app.d.ts` (8 LOC)
Ambient declaration extending `Window` with `__TAURI_INTERNALS__` and `isTauri` so `utils/runtime.ts` typechecks.
### `src/app.css` (583 LOC)
Layered:
1.`@import "tailwindcss"` (Tailwind v4 entry).
2.`@font-face` for Lexend, Atkinson Hyperlegible Next, OpenDyslexic, JetBrains Mono, Instrument Serif Italic. URLs use root-absolute paths (`/fonts/...`) served from `static/fonts/` by Vite.
3.`@theme` token block. Sets the design tokens that Tailwind v4 picks up as utility classes:
- Colour tokens: `--color-bg`, `--color-bg-raised`, `--color-text`, `--color-text-secondary`, `--color-text-tertiary`, `--color-accent`, `--color-warning`, `--color-success`, `--color-border`, `--color-border-subtle`, `--color-hover`, `--color-nav-active`, plus a sensory-zone palette family.
- Typography tokens: `--font-display`, `--font-body`, `--font-mono`, plus `--font-size-*` and `--line-height-*`.
- Shadow tokens: `--shadow-accent-md`, `--shadow-accent-glow`, etc.
4. Sensory-zone overrides keyed on `[data-zone="..."]` on `<html>`. Switches token values to dim, focus, etc.
5. Theme overrides keyed on `[data-theme="dark"]` and `[data-theme="light"]`. The `preferences` store writes both `data-theme` and `data-zone`.
6. Accessibility variables on `<html>`: `--font-family-body`, `--font-size-body`, `--letter-spacing-body`, `--line-height-body`. Set by the `preferences` store via `applyToDOM()`.
7. Base styles: `body` background, font, body font-family bound to the variable, scrollbar styling, focus ring.
8. Utility classes for grain texture (`.grain` uses the noise asset under `assets/grain.svg`), CRT-style transcript surface, etc.
9. Animations: `@keyframes fade-in`, `@keyframes pulse`, etc. Reduced motion guard at the bottom (`@media (prefers-reduced-motion: reduce)`).
### `src/fonts/` (bundled woff2)
-`atkinson-hyperlegible-next.woff2`
-`instrument-serif-italic.woff2`
-`jetbrains-mono.woff2`
-`lexend-variable.woff2`
-`opendyslexic.woff2`
Bundled by Vite (referenced from `app.css` via `/fonts/...` paths that Vite resolves). The same files live in `static/fonts/` for the static-served path. Confirm whether both paths are required; if Vite handles font copying, `static/fonts/` may be redundant.
### `src/assets/`
-`grain.svg`. Noise texture used by the `.grain` utility class.
-`waveform-mark.svg`. Brand glyph.
-`wordmark.svg`. Brand wordmark.
### `static/`
Served as-is from the webview root.
-`favicon.png`. Site favicon.
-`fonts/`. Same five woff2 files as `src/fonts/`. Likely the source of `app.css /fonts/...` URLs.
-`pcm-processor.js`. AudioWorklet processor (slice 02 owns the integration). 32-line file that converts microphone input to int16 PCM frames and posts them up.
-`textures/grain.png`. PNG version of the grain texture.
-`svelte.svg`, `tauri.svg`, `vite.svg`. SvelteKit defaults; technically unused. Candidate for removal.
## How preferences map to the DOM
| Preference | DOM target |
|---|---|
| `theme` ("light" / "dark" / "system") | `data-theme` on `<html>`. `system` resolves to OS preference at apply time. |
| `zone` ("default" / ...) | `data-zone` on `<html>`. |
| `accessibility.reduceMotion` | `<html data-reduce-motion="reduce|no-preference|system">`. Pairs with the `prefers-reduced-motion` media query. |
Plus the legacy `settings.fontSize` writes `--font-size-transcript` on `<body>` directly (`+layout.svelte:204`). That is separate from `accessibility.fontSize`.
## Tailwind v4 setup
- Installed via `@tailwindcss/vite` in `vite.config.js:3`.
- Entry: `src/app.css` line 1, `@import "tailwindcss"`.
- Tokens declared inline via `@theme` blocks in `app.css`.
- No `tailwind.config.{js,ts}` exists. Do not look for one.
- Class scanning: Tailwind v4 scans the source tree by default. Custom paths can be configured with `@source` directives if needed.
## Watch outs
- **Two font sources.** `src/fonts/` (Vite bundled) and `static/fonts/` (raw served). Confirm whether both are needed; redundancy bloats the bundle.
- **Mirror file `src/design-system/colors_and_type.css`** must be updated whenever `app.css``@theme` changes. There is no automated check.
- **`prefers-reduced-motion` honoured by CSS** but JS animations (e.g. `CompletionSparkline`'s stagger) are guarded in component code, not via the media query alone.
- **Tailwind v4 `@theme` is class-scanning sensitive.** If you put utility class strings inside conditional template literals that Tailwind cannot see at scan time, they will not be generated.
- **Removing default SvelteKit assets** (`static/svelte.svg`, `vite.svg`, `tauri.svg`) requires confirming nothing references them in `app.html` or `README.md`.
- **`pcm-processor.js`** is a static asset because AudioWorklet processors must be served from a same-origin URL. Bundling it through Vite would break the worklet registration. Leave it in `static/`.
## See also
- [Design system](design-system.md). The reference mirror of `app.css` tokens.
- [Stores](stores.md). The `preferences` store that writes the DOM.
- [Components](components.md). Where the utility classes are consumed.
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Components
**Plain English summary.** All 25 reusable Svelte 5 components, plus `Sidebar.svelte` which is the only component that lives at `src/lib/Sidebar.svelte` rather than under `src/lib/components/`. Grouped by what they do. Where a component is mounted directly by the shell (`+layout.svelte`), that is called out.
## At a glance
- **Path:** `src/lib/components/` (25 files), plus `src/lib/Sidebar.svelte`.
- **Conventions:** Svelte 5 runes (`$state`, `$props`, `$derived`, `$effect`). Tailwind v4 utility classes. CSS variables for design tokens. Lucide icons at 16/20/24px with `aria-label` next to or instead of the glyph.
## Shell mounts (always present)
| Component | LOC | Path | Purpose |
|---|---|---|---|
| `Sidebar` | 178 | `src/lib/Sidebar.svelte` | Left nav. Switches `page.current`. Collapsed mode shows tooltips. Renders task badge from `tasks.length`. |
| `Titlebar` | 80 | `src/lib/components/Titlebar.svelte` | Custom window chrome for non Linux (frameless windows). Min/max/close + drag area + sidebar aligned spacer. |
| `ToastViewport` | 143 | `src/lib/components/ToastViewport.svelte` | Bottom right toast stack. Reads from the global `toasts` store. Honours `prefers-reduced-motion`. |
| `ResizeHandles` | 67 | `src/lib/components/ResizeHandles.svelte` | Invisible 5 px margins for frameless resize. Linux uses native, so `+layout.svelte` suppresses this. |
| `FocusTimer` | 298 | `src/lib/components/FocusTimer.svelte` | Floating top right SVG progress ring. Listens for `lumotia:start-timer` window events and delegates to the focus timer store. Cancel hover, success flourish. Hidden on float and viewer windows by URL probe. |
| `MorningTriageModal` | 356 | `src/lib/components/MorningTriageModal.svelte` | Phase 5 modal. Self gated on `settings.ritualsMorning` and time of day. Mounted globally so it appears over any page. |
| `TaskSidebar` | 97 | `src/lib/components/TaskSidebar.svelte` | Optional right side dock that appears when `page.taskSidebarOpen`. Quick add input, top tasks, link out to the full Tasks page. |
## Settings building blocks
| Component | LOC | Purpose |
|---|---|---|
| `SettingsGroup` | 86 | Native `<details>` based progressive disclosure with animated chevron. Handler hooks (`onopen`) are used by SettingsPage to lazy probe expensive state (model lists, paste backends, etc). |
| `SegmentedButton` | 19 | Tiny segmented switch with ARIA radio role. |
| `HotkeyRecorder` | 217 | Captures a key combo. Uses `utils/hotkeyValidity.ts` to check the combo before persisting. |
| `ZonePicker` | 36 | Sensory zone (default / focus / dim / etc) picker that maps to a `data-zone` attribute on `<html>`. |
| `AccessibilityControls` | 112 | Font family, font size, letter spacing, line height, reduce motion, bionic reading. Writes via `updateAccessibility`. |
| `ImplementationRulesEditor` | 266 | Edits the implementation intentions list ("when X happens, do Y"). Persists via the `implementationIntentions` store. |
| `ModelDownloader` | 112 | Standalone download panel used inside Dictation when no model is loaded. Subscribes to `model-download-progress`. |
| `EmptyState` | 22 | Centred icon + title + body slot. Used widely. |
| `UnicodeSpinner` | 30 | ASCII spinner used while probing or downloading. |
## Tasks
| Component | LOC | Purpose |
|---|---|---|
| `WipTaskList` | 153 | Renders task rows with energy chips, completion checkbox, expansion to show micro steps, and a "start focus timer" button (dispatches `lumotia:start-timer`). |
| `MicroSteps` | 310 | Per task expansion: nudge bus integration, micro step suggestions from the LLM (`lumotia:microstep-generated`), step completion (`lumotia:step-completed`), per task implementation rules. |
| `EnergyChip` | 106 | Low/medium/high energy selector. Used on task rows and in TasksPage quick add. |
| `CompletionSparkline` | 92 | 7 day completion bar chart. Animated entrance with 30 ms stagger, respects `prefers-reduced-motion`. Reads from `recentCompletions` store. |
| `VisualTimer` | 33 | Compact visual timer pill used inside MicroSteps and the float window. |
## Transcripts
| Component | LOC | Purpose |
|---|---|---|
| `VirtualSegmentList` | 234 | Virtualised scroll for transcript segments in the viewer window. Uses `utils/virtualList.ts` and `utils/textMeasure.ts` to compute per row heights from current preferences. |
| `SpeakerButton` | 112 | Trigger TTS playback for a chunk of text. Uses `tts_speak`. Reads from the `speaker` store to debounce concurrent requests. |
| `LlmStatusChip` | 60 | Pill in the sidebar/dictation surface showing LLM state (idle, generating, downloading, unavailable). Reads `llmStatus` store. |
-`Titlebar` reads `settings.sidebarCollapsed` to mirror the spacer width. Animation timing is driven by `--duration-ui`.
-`MorningTriageModal` is heavy (356 LOC). Self gates internally; do not move the gate elsewhere or you will pay its cost on every paint.
-`FocusTimer` detects "secondary window" by URL prefix. If you add a fifth window, update the probe.
-`CompletionSparkline` animations are the only place that uses CSS keyframes for entrance. Honour reduced motion.
- Many components receive props with `let { foo = default } = $props();` (Svelte 5 idiom). The minimal `Card`, `EmptyState`, `Toggle`, `SegmentedButton` are good reading order for understanding the runes idiom on this codebase.
## See also
- [Stores](stores.md). The state these components consume.
- [Actions, utils, types](actions-utils-types.md). The bionic reading action and the typography utilities most components use.
- [Design system](design-system.md). The brand reference these components target.
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Design system
**Plain English summary.** Reference material. The runtime styles live in `src/app.css`. Everything under `src/design-system/` is brand documentation, preview pages, JSX UI kits, and ground truth source for the lumotia-design Claude skill. None of it is imported by the runtime SvelteKit app.
## At a glance
- **Path:** `src/design-system/`
- **Files:**
-`README.md`. Brand and content rules. (Lumotia by CORBEL.)
-`SKILL.md`. lumotia-design skill manifest (`user-invocable: true`). The skill copies assets out and writes throwaway HTML or production code on demand.
-`colors_and_type.css`. Mirror of the runtime `@theme` tokens for buildless preview pages. Intentional duplication, kept in sync by hand.
-`preview/`. 20 buildless HTML spec cards.
-`ui_kits/`. JSX recreations (`DictationPage.jsx`, `OtherPages.jsx`, `Sidebar.jsx`) plus an `index.html` and a README.
## What's in here
### `colors_and_type.css`
Mirrors `src/app.css``@font-face` declarations and (typically) the `@theme` token block, with relative `fonts/...` URLs so preview HTML can render without going through Tailwind/Vite. The file's banner comment is explicit:
> When app.css @theme changes, mirror the change here. There is no automated sync; intentional duplication keeps the preview pages buildless.
Three `.jsx` files plus `index.html` and a README. Reproduce the desktop pages in JSX so prototypes can be built outside the Tauri shell. The README inside ui_kits explains how to use them. They are not imported anywhere from the SvelteKit app.
### Brand language (from README.md)
- Built for the noise allergic. Solarpunk-as-posture, AI-minimisation, ground truth + warmth.
- Iconography: Lucide, 2 px stroke. 16 px nav, 20 px features, 24 px primary actions. Always paired with a label except OS titlebar controls.
- Transparency + blur used sparingly: collapsed-sidebar tooltips, float window backdrop, accent-subtle 6% tint. No glassmorphism.
## Why it lives in `src/`
The design system files sit under `src/` so the lumotia-design skill (which runs from this repo) can read ground-truth Svelte source from `lumotia-source/` (a sibling import). The runtime build does not include this folder; it is a reference root.
## Watch outs
- Tokens in `colors_and_type.css` and `app.css``@theme` can drift. There is no CI check. When you change a token in one, change it in both.
-`ui_kits/` JSX is not used by the app. Treat it as documentation, not as a future framework migration plan. There is no plan to switch from Svelte to React.
- Vite (with `sveltekit()` plugin) ignores `.html` files outside `static/` and `.jsx` files outside imports. If anyone adds an `import './design-system/...'` somewhere, the build will start picking up reference material. Do not.
- The skill is user invocable (`SKILL.md`). External agents may write into `src/design-system/` based on user invocation. Treat the contents as agent-shaped, not human-only.
## See also
- [App shell and styling](app-shell-and-styling.md). The runtime `app.css` that this folder mirrors.
- [Components](components.md). The Svelte versions of the JSX kits.
**Plain English summary.** The complete surface where the Svelte frontend talks to the Rust runtime. Two channels: synchronous request/response via `invoke()`, and asynchronous events via `listen()` / `emit()`. Every command name and every event name the frontend touches is listed here so slice 02 can verify reciprocal mentions.
## At a glance
- **Direction in (Rust → frontend):** events listed under "Tauri events listened to".
- **Direction out (frontend → Rust):** all `invoke()` commands listed under "Tauri commands invoked".
- **DOM-only events:** `lumotia:*``CustomEvent`s on `window`. These never cross the Rust boundary.
- **Cross-window event:** `lumotia:preferences-changed` is the only one that goes through `emit()`.
## Tauri commands invoked (alphabetical)
Discovered via `grep -rho 'invoke([\"\\']\\([a-zA-Z_][a-zA-Z0-9_]*\\)' src/`. Each name appears at least once in the frontend.
Plus any commands invoked by `saveMarkdown.ts` via the dialog/file-write plugin imports rather than direct `invoke`. (Verified the truncated `tts_s...` resolves to `tts_speak` and `tts_stop`; both are listed.)
| `@tauri-apps/plugin-notification` | Available, used by nudge bus or a related path. Confirm specific call sites. |
| `@tauri-apps/plugin-opener` | Available, used to open external URLs. Confirm specific call sites. |
## Watch outs
- The truncated grep returned `tts_s...` as a unique prefix. Verify every TTS command name (`tts_speak`, possibly `tts_stop`) against `lib.rs` to make sure the table above is exhaustive.
- The "preview-*" events have two flavours: `lumotia:` namespaced (`preferences-changed`) and bare (`preview-listening`, `preview-cleanup`, `preview-hide`, `preview-append`, `preview-final`). The bare names are scoped to the overlay window's two-way handshake. Do not rename.
-`lumotia:open-wind-down` listener is in `+layout.svelte` so the page opens regardless of which window the tray click came from. If you ever isolate windows further, this assumption breaks.
-`task-window-focus` is the only event sent specifically to the float window.
- DOM `CustomEvent` handlers do not survive across windows (they are window-local). The tasks list refresh trick on focus relies on this.
## See also
- [Windows and routes](windows-and-routes.md). For the listener mounting points.
- [Stores](stores.md). For the dispatch points of intra-frontend events.
- [../02-tauri-runtime/README.md](../02-tauri-runtime/README.md). For the matching Rust handlers (slice 02 will mirror this table from the other side).
> **Where you are:** [Architecture map](../README.md) → [Frontend](README.md) → Internationalisation
**Plain English summary.** svelte-i18n is wired but coverage is intentionally minimal. The infrastructure (loader, persisted choice, Settings selector) is in place so strings can migrate incrementally. Three locales: English (source of truth), Spanish, German. Most strings still render hardcoded.
## At a glance
- **Path:** `src/lib/i18n/`
- **LOC:** 73 (`index.ts`) + 19 lines per locale JSON.
- **Key files:**
-`src/lib/i18n/index.ts`. Setup, locale detection, public exports.
-`initI18n()`. Idempotent. Called from every layout's `onMount`-ish path (`+layout.svelte:38` and the float/viewer/preview break layouts).
-`setLocale(code)`. Writes to `lumotia_locale` and updates the svelte-i18n store.
-`currentLocale`. A derived store that components subscribe to via `$currentLocale`.
-`SUPPORTED_LOCALES` constant for the picker.
### Locale JSON shape
19 lines per file means roughly a dozen translated strings. Treat the locales as a stub. Most page text is still hardcoded.
## Where it is consumed
-`SettingsPage.svelte:22` imports `_` (the translation function) and `SUPPORTED_LOCALES`, `setLocale`, `currentLocale`. The locale picker UI lives in Settings.
- A handful of strings inside SettingsPage use `$_('key')`.
## Watch outs
- Adding a new locale is one `register("xx", () => import("./locales/xx.json"))` call plus a `SUPPORTED_LOCALES` entry.
- Cross window: each window initialises i18n independently via its layout. Locale change persists to `localStorage["lumotia_locale"]`. Float and viewer pick up the new locale on the next mount, not live. Worth wiring a Tauri event if live cross-window translation is needed.
- The "British English" toggle in `settings.britishEnglish` is a transcription post-processing flag (slice 04 territory), not a UI locale. Not wired through svelte-i18n.
- Default locale is detected from `navigator.language`. In Tauri, this is the OS locale.
## See also
- [Pages: settings](pages/settings.md). The locale picker UI.
- [Stores](stores.md). The `currentLocale` derived store.
**Plain English summary.** Lumotia has seven main window pages and three secondary window pages. The main window picks which to show by reading `page.current` (a string in the `page` store) and rendering the matching component. The secondary windows are routed by URL (`/float`, `/viewer`, `/preview`).
- Possible values of `page.current`: `"first-run" | "dictation" | "files" | "tasks" | "history" | "settings" | "shutdown"`. Plus the legacy `"profiles"` which is rewritten to `"settings"` (see README debt note 5).
## Map of pages
### Main window
| Page | File | LOC | One line hook |
|---|---|---|---|
| Dictation | `src/lib/pages/DictationPage.svelte` | 1 100 | The hero recording surface. Streams partial transcripts via a Tauri `Channel`, runs the AI cleanup pipeline, paste/copy/export, extracts tasks, fills a template. |
| Tasks | `src/lib/pages/TasksPage.svelte` | 725 | Inbox/today/soon/later board with energy chips, lists, completion sparkline. |
| Files | `src/lib/pages/FilesPage.svelte` | 263 | Drop or browse audio/video files. Calls `transcribe_file`. |
| First run | `src/lib/pages/FirstRunPage.svelte` | 337 | Hardware probe (`probe_system`), model recommendation, model download. Auto exits to dictation. |
| Shutdown ritual | `src/lib/pages/ShutdownRitualPage.svelte` | 169 | Evening wind down ritual. Triggered from the tray menu via `lumotia:open-wind-down`. |
### Secondary windows (URL routed)
| Window label | URL | File | LOC | One line hook |
|---|---|---|---|---|
| `tasks-float` | `/float` | `src/routes/float/+page.svelte` | 481 | Detached pinned tasks window with quick add and list management. |
| `transcript-viewer` | `/viewer` | `src/routes/viewer/+page.svelte` | 606 | Transcript editor with synced audio playback and per segment editing. |
| `transcription-preview` | `/preview` | `src/routes/preview/+page.svelte` | 274 | Borderless overlay showing live transcription with copy and revert. |
## How navigation actually happens
- Sidebar buttons set `page.current` directly (`src/lib/Sidebar.svelte:24`).
- Some flows reach into `page.current` from outside the sidebar:
The shell renders sidebar plus a single main slot: `<div class="flex-1 overflow-hidden bg-bg">{@render children()}</div>`. The optional task sidebar (`page.taskSidebarOpen`) can dock a `TaskSidebar` panel beside any main page that is not first run.
**Plain English summary.** This is the recording surface. Press the hotkey or the mic button, watch live partial text stream into a textarea, then on stop run the AI cleanup, extract tasks, copy to clipboard or paste into the focused application. The page handles model loading, the live transcription session, the preview overlay, optional template insertion, and task extraction.
## At a glance
- **Path:** `src/lib/pages/DictationPage.svelte`
- **LOC:** 1 100
- **Imports (selected):**
- Tauri: `Channel`, `invoke` from `@tauri-apps/api/core`. `emit` from `@tauri-apps/api/event`. `getCurrentWindow` from `@tauri-apps/api/window`.
- Stores: `page`, `settings`, `templates`, `profiles`, `addToHistory`, `addTask`, `tasks` from `page.svelte.ts`. `markGenerating`, `markGenerationDone` from `llmStatus.svelte.ts`. `profilesStore` from `profiles.svelte.ts`. `toasts`. `getPreferences` from `preferences.svelte.ts`.
-`runtimeCapabilities` (from `get_runtime_capabilities`, used to decide engine availability and CUDA presence).
### Lifecycle
-`onMount`. Loads `runtimeCapabilities` (`DictationPage.svelte:134`). Sets up the global hotkey custom event listener (`lumotia:toggle-recording`, dispatched from the `+layout.svelte` hotkey path).
-`onDestroy`. Tears down listeners and timers.
### Recording flow (high level)
1. Toggle from the mic button or the `lumotia:toggle-recording` window event.
2. If model not ready, ensure model: check `check_engine`, `check_parakeet_engine`, `check_llm_model`, then load via `load_model` / `load_parakeet_model` / `load_llm_model` as required (`DictationPage.svelte:241-272`).
3. Open two `Channel<message>` instances (`DictationPage.svelte:341-342`) and call `start_live_transcription_session` with the channel handles.
4. As the backend pushes status and partial result messages, append text to `transcript`, update `segments`, and broadcast `preview-append` to the preview overlay window (`DictationPage.svelte:165, 381, 385`). If the preview is enabled and not already open, `open_preview_window` is invoked.
5. On stop, call `stop_live_transcription_session`, then run AI cleanup (`cleanup_transcript_text_cmd`) gated on `get_llm_status` (`DictationPage.svelte:464-472`).
6. Optionally extract tasks via `extract_tasks_from_transcript_cmd` (`DictationPage.svelte:487-491`) and add them via the `addTask` store helper.
7. Push to history with `addToHistory` (which calls `add_transcript`).
8. Auto copy and/or auto paste based on `settings.autoCopy` / `settings.autoPaste`. Paste path uses `paste_text` (`DictationPage.svelte:544-554`); fallback to `copy_to_clipboard`.
9. Emit `preview-cleanup`, `preview-final`, `preview-hide` to drive the overlay state machine (`DictationPage.svelte:531, 539, 606`).
Plus `emit("preview-append" | "preview-listening" | "preview-cleanup" | "preview-final" | "preview-hide")`.
## Watch outs
- The page uses `// @ts-nocheck`. Adding type safety here would catch a class of regressions, especially around the `Channel<T>` payload shape.
- The cursor based insertion (`insertPos`) is fragile. Editing the textarea while a live session is running can corrupt the segment offset map. Tests around `extractTasks` only cover the post stop path.
- Multiple paths can flip `recording` (button, hotkey custom event, error early-out). Treat the page as a state machine with a single source of truth and you will save yourself.
-`extractedCount` is purely cosmetic. It is reset on recording start.
- The "live activity" stalled detector is timer based (`lastLiveActivityAt`). On a stalled session the user sees `liveWarning`. The recovery path is "stop and start again".
- [../03-audio-transcription/README.md](../../03-audio-transcription/README.md). Live session plumbing on the Rust side.
- [../04-llm-formatting-mcp/README.md](../../04-llm-formatting-mcp/README.md). Cleanup and task extraction commands.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.