Two new docs in docs/hardware/:
- pendant-research-2026-04-27.md — buildable plan for a Corbie-paired
open-hardware audio capture device. Nordic nRF5340 silicon (LE Audio
is effectively a Nordic monopoly outside Apple/Samsung), Raytac
MDBT53-1M pre-cert module to dodge GBP 8-15k EMC chamber bill, drop
Wi-Fi for USB-MSC sync, single Knowles SPH0645 mic, microSD disguised
as cassette spool, hardware-locked LED in series with mic V_DD,
Sifam analogue VU meter, Sony WM-D6C / Nagra E / Playdate / TP-7
aesthetic. ~GBP 107 BOM at qty 100. Funding sequence: NLnet first
(deadline 2026/06/01), Corbie waitlist soft pre-orders second,
Crowd Supply third. 22-month timeline, ~GBP 1,200 founder personal
capital exposure.
- nlnet-genai-policy.md — verbatim NLnet GenAI policy v1.1 with TL;DR
and a Corbie-specific compliance plan. Read before drafting any
NLnet application or doing GenAI-assisted work on a funded milestone.
Roadmap entry under "Post-v0.1 ideas -> Hardware companion" rewritten
to match the compass research. The earlier off-the-cuff Tier-A/Tier-B
sketch (Hailo, Wi-Fi 6, three-mic array) is wrong on most decisions
and is superseded.
Hard discipline: hardware never ships before Corbie software hits
GBP 2k MRR.
Storage-side scaffolding for B3.3 (granularity prompt variants) and
B3.10 (mastery fade). The user-facing wiring (decompose_and_store
granularity threading, complete_subtask_cmd count bump, MicroSteps
inline prompt + skip-check, Settings UI) is part 2 of this batch.
B3.3 (prompts.rs): three new system-prompt constants
DECOMPOSE_LIGHT_SYSTEM (3 atomic verb-first steps),
DECOMPOSE_DEFAULT_SYSTEM (4-5 balanced steps), DECOMPOSE_DETAILED_SYSTEM
(6-7 with brief context) all preserve the cue-anchored "When [cue],
[action]" framing from PR 1.1. DECOMPOSE_TASK_SYSTEM remains as an
alias of Default for back-compat with existing callers and tests.
4 snapshot tests assert the framing survives across variants and the
alias matches.
B3.10 (storage): migration v18 adds microstep_patterns
(normalized_title PK, sample_title, completed_count, skip_breakdown,
prompted_at) plus an index on completed_count. New storage functions:
normalize_microstep_title (lowercase + whitespace-collapse + trim),
get_microstep_pattern, upsert_microstep_pattern_increment,
set_microstep_pattern_decision. 5 storage tests cover normalisation,
upsert/bump semantics, decision persistence, and the threshold query.
74 storage tests pass (was 69), 9 prompts tests pass (was 5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
settings.nudgeMode (Off / Immediate / Digest) replaces the binary
nudgesEnabled toggle in the Settings UI; the field stays in storage
mirrored from nudgeMode for back-compat readers. Digest mode queues
trigger events (deduped by key) into a localStorage-persisted buffer
and delivers them as a single aggregated notification at user-chosen
times of day (1–3 slots). Body shows up to 3 lines with a "...and
N more" truncation tail and honours nudgesSpeakAloud. Switching from
digest to off clears the queue; digest to immediate replays.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B3.1: app shell tracks lastLaunchAt; >7-day gap opens a 24h
fresh-start window. While the window is active: a "Welcome back. This
week starts fresh." banner offers Archive old Inbox (Inbox-only via
archive_old_inbox_cmd, system-attributable copy on success), the
morning-triage modal is suppressed, the momentum sparkline is hidden,
and the nudge bus stays quiet. Banner is per-session dismissable.
B3.4: MicroSteps shows a "Last completed: X. Next: Y." re-orientation
banner when re-opening a parent task whose decomposition has been idle
>=30 min OR sat across a session boundary. Speaker button reads the
banner aloud via existing TTS. Per-parent last-activity timestamps are
persisted to a single localStorage key. Banner self-dismisses on first
interaction.
Shared inFreshStartWindow gate extracted to src/lib/utils/freshStart.ts
so ShutdownRitualPage (B3.14), TasksPage, MorningTriageModal, and
nudgeBus all read the same source of truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings → Energy: edit global energy labels (label + description per
level) with reset-to-defaults. Per-profile override toggle in the
Profiles section ("Customise for this profile" / "Use global").
EnergyChip and Tasks-page energy controls now resolve labels via
resolveEnergyLabels(profile, settings) — global by default, profile
override when present.
First-time prompt: on first energy-chip tap of the day (per profile)
or first-ever tap to Zero, an inline non-modal prompt asks "What does
Zero mean for you today?" with example chips. Per-day gate via
energyPromptLastShownDate; one-shot first-Zero gate via
energyPromptZeroSeen; Settings escape resets both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B3.5: settings.sparklineRangeDays (added schema-only in B2b) now drives
the recentCompletions fetch via a reactive effect; new segmented control
in Settings → Tasks lets the user pick 7 / 28 / 90 days. Default 7.
B3.14: /shutdown "Open loops" header softened to "Still here", body
copy updated to remove pressure ("Just naming them helps — nothing to
do here"), truncation tail drops the count. The whole "Still here"
section is now gated by a fresh-start derived flag — when B3.1 lands
and writes reentryFreshStartUntil, this surface auto-suppresses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings: 8 new fields (lastLaunchAt, reentryFreshStartUntil,
lastActiveProfileId, microStepGranularity, nudgeMode, nudgeDigestTimes,
sparklineRangeDays, energyLabels) with sane defaults; nudgesEnabled
auto-maps to nudgeMode on first load (true→immediate, false→off).
Profile: optional energyLabelsOverride field on the localStorage
Profile shape — when present, overrides the global energyLabels;
absent/null falls through to settings.energyLabels via the new
resolveEnergyLabels helper.
Templates: migration v17 adds the templates table (id PK, name,
sections JSON, created_at, updated_at). 5 storage CRUD functions
plus import_templates with idempotent duplicate-id handling. 5 Tauri
commands (list/create/update/delete/import) wired and registered.
Frontend store rewired to read from SQLite via list_templates_cmd;
one-time migration imports kon_templates localStorage and clears it
on success. Fresh installs only seed the new {Meeting notes, Daily
check-in} defaults. Existing user templates survive the migration
verbatim.
Test coverage: 5 new Rust tests (CRUD, import idempotency, atomic
failure, updated_at bump, missing-id delete is no-op).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Storage: 5 task_lists CRUD functions (list/create/update/delete/
import) and 4 archive functions (list_archived_tasks/archive_task/
unarchive_task/archive_inbox_older_than). 8 new tests cover CRUD,
import idempotency, atomic-failure rollback, dependent-task null-out,
archive scope, archive_inbox_older_than Inbox-only invariant, and
unarchive round-trip.
Tauri: 9 new commands (5 task_lists + 4 archive) registered in the
invoke_handler. New file commands/task_lists.rs mirroring tasks.rs
structure. TaskDto extended with archived/archivedAt.
Frontend: loadTaskLists rewired to read from SQLite via
list_task_lists_cmd on Tauri runtime, with a one-time migration
that imports kon_task_lists localStorage into SQLite via
import_task_lists_cmd and clears localStorage only on success.
mapTaskRow handles the new archived fields. TasksPage gets an
Archived filter pill (hidden when empty) and a per-row archive
icon. moveTaskList/sortTaskLists kept local pending a future
order-column migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration v16 adds tasks.archived (default 0) + tasks.archived_at +
idx_tasks_archived. TaskRow extended with both fields; list_tasks and
list_subtasks default-filter archived rows; complete_subtask_and_check_parent
SELECT lists updated to round-trip the new columns. TaskListRow +
ImportSummary structs land in preparation for the task_lists CRUD
that part 2 wires up. All 61 existing tests still pass.
(SQL splitter is naive about ';' inside -- comments, so the v16
comment block was rewritten to avoid a bare semicolon mid-comment
that the splitter was treating as a statement boundary.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Track draft-handled-this-session flag so Restore/Discard suppresses
the recovery banner on page-nav round-trips within one session;
resets cleanly on the next successful save.
- Flush pending autosave in onDestroy so Tauri SPA navigation
preserves the last <1.5s of input.
- Round-trip language on Restore so a draft captured under one
language doesn't silently inherit the current setting on revival.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
addToHistory previously swallowed SQLite write failures with a console
warning while still pushing the entry into the in-memory history store
— a silent data-loss bug, since the row vanished on next restart with
no signal to the user. It now returns { persisted, error }, only mutates
in-memory history after the disk write lands, and the dictation flow
awaits the result so it can surface a "Couldn't save" toast and keep
the user's content intact for a retry. The new dictation autosave
writes transcript + segments to localStorage on a 1.5s debounce, on
every segment boundary, and on visibilitychange/pagehide — covering
both live capture and the stopped-but-unsaved walk-away case. On
DictationPage mount any draft <24h old surfaces an inline
Restore/Discard banner that styles as a peer to the existing error
notices. The draft is cleared only after a successful SQLite write or
explicit Discard/Clear; on persistence failure it stays on disk so the
user can recover on relaunch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flips the Tasks landing bucket from All to Today so the cold-open answer to
"what should I do now?" is scoped to today's commitments rather than the
full backlog. Refactors WipTaskList to accept a tasks prop instead of
reading the global store, and wires it into TasksPage so the Now lane and
the bucket list below share one filtered set as their source of truth —
the lane consumes the first three top-level rows and the bucket list
excludes those IDs, so no task is ever rendered in both panels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Affordances on MicroSteps rows (thumbs/edit/speaker/just-start) and
transcript-viewer segments (star, copy, delete) used opacity-0 →
opacity-100 on group-hover, which (a) hid them from keyboard users
until focus and (b) violated the "always-visible affordance" line in
the Kon design audit.
Replace with opacity-30 baseline + group-hover:opacity-100 +
focus-visible:opacity-100 (MicroSteps) / focus-within:opacity-100
(viewer, since the actions sit inside an interactive segment row).
Result: affordances are calm but visible at rest, lift on hover or
keyboard focus, never invisible.
HistoryPage was checked — its row checkbox at line 757 is already
opacity-50 → opacity-100 on hover, the pattern this PR is moving
toward, so no change needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Change complete_subtask_and_check_parent to return
(TaskRow, Option<TaskRow>): the refreshed subtask, plus the parent when
the cascade auto-completed it. Both rows are read inside the same
transaction so the frontend never observes partial state.
Wrap the cmd in a CompleteSubtaskResult { updatedSubtask,
autoCompletedParent } DTO. MicroSteps now applies the refreshed subtask
locally and, when present, calls a new replaceTaskFromDto helper on the
Tasks store so the parent's done/doneAt/badge counts update without a
follow-up list_tasks_cmd round-trip.
Test: complete_subtask_returns_updated_subtask_and_optional_parent
covers both paths (sibling-pending → None, last-sibling → Some(parent)).
All 61 kon-storage tests pass; svelte-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add cue-anchored "When [cue], [action]" framing to the task-decomposition
prompt where natural cues are present (Gollwitzer-style implementation
intentions, d=0.65 effect size). Soften Bionic Reading and accessibility-
font copy to honest preference framing per the v3 audit (Strukelj 2024;
Doyon n=2,074). Update timer nudge from "Still on that timer?" (which
read as judgmental) to "Timer's still running." Replace stale Tasks
page header copy promising automatic extraction.
Audio envelopes (focusTimer 20ms ramp, sounds.ts 10ms attack) verified
correct per memo §B; no code change needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Map every recommendation in research-grounded-design-principles.md to
Kon's current code state, with two independent axes (alignment +
evidence strength) and prioritised gap tiers. Tier 1 limited to
single-PR-sized work; body-doubling, partner-sharing, and personal
acoustic adaptation recorded as out-of-product-scope, not backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles a session of dogfood UX feedback plus the two Cursor Bugbot
findings on the auto-titles branch.
Onboarding (FirstRunPage):
- Welcome leads with "Set up automatically"; system breakdown and the
full model list move behind a "Choose manually" disclosure
- Morning / evening / autostart modal copy trimmed to one short
sentence each; CTAs shortened
- "Corbie" autostart string reverted to "Kon"
- Already-downloaded models are clickable in the picker so the
Settings → About → Replay onboarding flow doesn't re-download
- Autostart "No thanks" now does isEnabled() → disable() to actually
remove the OS login item when replaying after a previous "Yes"
Tasks page:
- Bucket nav (All / Inbox / Today / Soon / Later) now a horizontal
pill row; was stacking because nav was block-level
- List sidebar sized to content via self-start max-h-full instead of
stretching to viewport when sparse
- Energy chip surfaces at opacity-60 when unset (was opacity-0,
hidden until hover) so the affordance is discoverable
- "Brain-Dead" energy label → "Zero" everywhere user-facing; enum
stays brain_dead to avoid a destructive DB migration
LLM status chip (llmStatus.svelte.ts + Dictation/Settings):
- Chip no longer auto-warms when the engine isn't loaded; it's hidden
unless ready / generating / loading / error
- refreshLlmStatus takes { force: true } so post-load reconcile clears
stale "warming"; ambient refreshes still preserve in-flight state
- markError exported; failed loads surface "AI error" with detail
rather than silently going to off
- check_llm_model is the source of truth (replaces the bool-only
get_llm_status path in the store)
Float popout window:
- Native decorations off — was stacking two titlebars + two close X's
on KWin, one of which silently failed
- ResizeHandles mounted outside the animate-float-enter wrapper so
fixed-position handles anchor to the viewport, not the transformed
root; secondary-windows capability gains
core:window:allow-start-resize-dragging for tasks-float
- GTK Utility WindowTypeHint applied pre-map (mirroring the preview
window) so KWin Wayland honours always-on-top reliably
- visible_on_all_workspaces(true) so the pinned tasks list follows
workspace switches
- togglePin does hide()+show()+focus() on re-pin to nudge the
compositor into re-evaluating window state
- Pop-out / Edit / Open viewer buttons hidden on Android via
isAndroid() — the multi-window Tauri commands stub out there
Build / Bugbot:
- src-tauri Cargo.toml: whisper feature now chains whisper-vulkan, so
the dev runner's --no-default-features --features whisper
invocation actually pulls Vulkan acceleration instead of silently
falling back to CPU-only
- jsconfig.json's inherited "types": ["node"] fixed by adding
@types/node; corresponding @ts-expect-error in vite.config.js
removed now that process is a known global
Verification: svelte-check + cargo check pass clean. Manual
device-side validation still pending for float resize and replay
autostart "No thanks" — those are the only remaining confidence items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire generate_title_cmd into the user-visible flows. Three entry points,
matching the agreed hybrid trigger (auto when conditions are met, plus
on-demand for retroactive titling of old transcripts):
1. Auto on save — addToHistory in src/lib/stores/page.svelte.ts now
fires `maybeAutoGenerateTitle` fire-and-forget after the SQLite
write succeeds. Gate: `aiTier !== "off" && formatMode !== "Raw"`,
piggybacking on the same Settings choice that drives auto-cleanup.
Per the design principle "Every new setting must earn its mental
real estate" (README.md:22), no new settings flag. Skipped silently
when LLM isn't loaded; user retries via the per-row button. Never
overwrites a title the caller already set.
2. Per-row "Title" button in HistoryPage — mirrors the existing Tag
button shape exactly (Sparkles icon vs Tag icon). In-flight tracked
via a `titling: Set<string>` so the same row can't fire twice; the
row stays disabled with "Titling…" copy while in flight. Persists
via the existing `renameHistoryEntry` (which already calls
`update_transcript`) — no need to extend `saveTranscriptMeta`.
3. Bulk "Title all untitled" toolbar action — same shape as
"Tag all untagged". Filters `history` to entries where
`!item.title || !item.title.trim()`, iterates with progress
text, surfaces a success toast at the end with the count actually
written (not iterated — empty model responses count as skipped).
Visible with no schema changes, no migration: transcripts.title has
been nullable since v1 (migrations.rs:12-29).
Verification:
- `npm run check`: 0 errors, 0 warnings across 3957 files.
- `cargo test --workspace --lib`: 280 passing (was 277; +3 from the
sanitize_title tests landed in the kon-llm commit).
Together with the prior two commits this closes the "auto-titles"
plan from /home/jake/.claude/plans/delightful-meandering-moth.md.
Out of scope per that plan: settings toggle, regenerate-on-edit,
i18n-locale-matching titles.
Bridge LlmEngine::generate_title across the Tauri boundary. Same shape
as the existing extract_content_tags_cmd — `is_loaded` short-circuit so
the frontend can detect the "no model" case without firing a synchronous
inference, then spawn_blocking + PowerAssertion guard for the heavy work.
- src-tauri/src/commands/llm.rs: new generate_title_cmd that wraps
`state.llm_engine.generate_title(transcript)`. Returns
Result<String, String> — the engine's own InvalidJson / Inference /
PromptTooLong errors are stringified at the boundary, same pattern
as extract_content_tags_cmd at line 408.
- src-tauri/src/lib.rs: register the command in invoke_handler!,
immediately after extract_content_tags_cmd in the LLM block.
Verified: `cargo check -p kon` passes after a `rm -rf target/.../tauri-*`
to clear stale OUT_DIR paths from the project's pre-rename location
(transcription-app used to live at ~/CORBEL-Projects/kon/). No code
behaviour change from that cleanup — Cargo just needed the cache rebuilt.
The frontend wiring follows in the next commit.
Recorder-style auto-titling for transcripts. Mirrors the Phase 9
content-tags pipeline so the same prompt-injection-hardened pattern,
spawn_blocking discipline, and sanitisation-after-generation shape get
reused; the user-facing surface (auto on save + on-demand button) lands
in a follow-up commit.
- crates/llm/src/prompts.rs: new TRANSCRIPT_TITLE_SYSTEM constant. Same
injection guard wording as ai-formatting's CLEANUP_PROMPT — dictated
speech is data, not instructions. Rules constrain output shape: 4-8
words, Title Case, no quotes, no terminal punctuation, "Untitled"
fallback for empty input.
- crates/llm/src/lib.rs: LlmEngine::generate_title returns
Result<String, EngineError>. Mirrors extract_content_tags shape:
trailing-2000-char UTF-8-boundary truncation, temperature 0,
max_tokens 24, free-form output (no GBNF — titles are prose, not a
closed set). Sanitisation runs server-side via the new private
sanitize_title helper, which handles the real Qwen3 failure modes:
surrounding curly + ASCII quotes, leading "Title:" prefix, multi-line
output, trailing "." / "!" / "?", whitespace runs, 100-char cap,
literal "Untitled" → None. Three unit tests cover composite real-world
outputs end-to-end. kon-llm test suite goes 15 → 18 passing.
The Tauri wrapper, invoke_handler registration, and frontend wiring
follow in subsequent commits.
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>