Commit Graph

145 Commits

Author SHA1 Message Date
b333c6229e chore(hardening): tighten security and footprint defaults 2026-04-24 19:03:57 +01:00
1d4f1070a2 feat(energy): Phase 3 — match-my-energy task sort + tri-state tag column
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
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.
2026-04-24 14:53:19 +01:00
d307722c7a fix(feedback): Phase 2 follow-up — Codex review MAJORs + NIT
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
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.
2026-04-24 13:32:52 +01:00
46be0a5aca feat(feedback): Phase 2 — HITL thumbs + correction capture with prompt-conditioning loop
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
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)
2026-04-24 12:53:51 +01:00
4700668df1 docs(readme): refresh test count 136 -> 245
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
2026-04-24 09:53:26 +01:00
fe61661305 chore(lint): clean up clippy warnings across workspace
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).
2026-04-24 09:43:56 +01:00
9b0067b4c0 Land release blocker fixes and workspace cleanup
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
2026-04-23 00:16:09 +01:00
05d823bf05 fix(rb-02): migrations run inside a single transaction
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>
2026-04-22 10:27:29 +01:00
b48d39bfb1 fix(rb-09): decoder propagates read and decode errors
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>
2026-04-22 10:24:18 +01:00
528facfab0 fix(rb-12): hotkey device filter consults configured HotkeyCombo
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>
2026-04-22 10:18:54 +01:00
a5bc45e847 fix(cr-2026-04-22): list_transcripts accepts omitted arguments
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 09:23:17 +01:00
b376b98f33 refactor(cr-2026-04-22): remove dead code and stale allow(dead_code) suppressions
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).
2026-04-22 09:17:05 +01:00
840012822f fix(cr-2026-04-22): list_transcripts tool returns -32602 on malformed params
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.
2026-04-22 09:14:42 +01:00
d25b095788 fix(cr-2026-04-22): MCP stdio replies with parse-error on malformed JSON
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.
2026-04-22 09:13:33 +01:00
53d303f4b7 fix(cr-2026-04-22): uncomplete_task reopens auto-completed parents
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.
2026-04-22 09:11:14 +01:00
93a7165dac fix(cr-2026-04-22): reject HTTP 4xx/5xx on non-resume download path
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.
2026-04-22 09:09:09 +01:00
b665754560 fix(cr-2026-04-22): read_wav surfaces sample decode errors
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.
2026-04-22 09:07:55 +01:00
0ea230fef4 fix(cr-2026-04-22 C2): VadChunker::flush returns Vec<VadChunk> and preserves mid-flush emissions
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.
2026-04-22 09:03:50 +01:00
e54f0404ce fix(A.4 #29): strip zero-width format chars in to_plain_text
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.
2026-04-22 08:37:43 +01:00
53fe848979 feat(A.4 #29): plain-text pre-formatter before LLM cleanup
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.
2026-04-22 08:34:04 +01:00
4c1d368d05 fix(A.3 #25): guard sample_index_for_seconds against NaN and infinity
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.
2026-04-22 08:02:23 +01:00
ed90de3c93 fix(A.3 #21): preserve audio contiguity across max_chunk splits in RmsVadChunker
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.
2026-04-22 08:01:14 +01:00
4455e4d1b1 fix(A.3 #24): clamp LocalAgreement slices against latest.len() to prevent panic on shrinking passes
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)
2026-04-22 07:59:27 +01:00
cea15c12c7 feat(A.3 #25): aggressive buffer trim tied to commit points
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.
2026-04-22 07:52:48 +01:00
da2340325f feat(A.3 #24): LocalAgreement-n commit policy
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.
2026-04-22 07:50:18 +01:00
05eea41649 feat(A.3 #21): VadChunker trait + RMS backend (Silero deferred)
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.
2026-04-22 07:47:30 +01:00
f9b396a966 feat(A.2 #19): progressive WAV write during live capture
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.
2026-04-22 04:39:44 +01:00
8b49d0fe9c feat(A.2 #13): replace SpeechBackend enum with Transcriber trait
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.
2026-04-22 04:33:23 +01:00
dd98cb7994 fix(A.1 #8): make ResumeUnsupported test actually prove Range header was sent
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.
2026-04-22 00:36:37 +01:00
2371e73f18 test(A.1 #8): cover ResumeUnsupported restart path in transcription downloader
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).
2026-04-22 00:29:13 +01:00
ce2b4fdac6 feat(ai B.1 #15 + A.1 #28): cleanup presets and sequential-GPU guard
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
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>
2026-04-21 17:12:48 +01:00
1dd09e14ca feat(ai-formatting A.1 #26): detect prompt-loop repetition cascades
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
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>
2026-04-21 16:44:22 +01:00
f525004d05 feat(ai-formatting A.1 #22): expand hallucination blocklist for subtitle-training leakage
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
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>
2026-04-21 16:41:55 +01:00
42ba18a274 feat(ai-formatting B.1 #16): reframe CLEANUP_PROMPT as translator, not editor
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
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>
2026-04-21 16:23:06 +01:00
Cursor Agent
9266bf5463 feat(A.1 #8): harden transcription model downloads with sha + resume tests
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>
2026-04-21 15:54:15 +01:00
Cursor Agent
770516460d feat(A.1 #7): runtime CPU feature detection (AVX2/FMA/AVX-512/SSE4.2/NEON)
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>
2026-04-21 15:54:15 +01:00
Cursor Agent
1bb39699f5 feat(A.1 #6): fail Windows build when tokenizers enters the dep graph
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>
2026-04-21 15:54:15 +01:00
26b41389b2 perf(sqlx): strip default features — workspace uses none of macros / migrate / any / json
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>
2026-04-21 15:42:04 +01:00
6837700ac9 style(clippy): clean up the two lints in phase3 new code
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>
2026-04-21 09:06:47 +01:00
42b32a4f1a test(storage): pin FTS5 contract for search_transcripts
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>
2026-04-21 07:55:52 +01:00
ba0d59f563 feat(meeting): opt-in process-list reminder when a meeting app starts
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>
2026-04-21 07:54:55 +01:00
63e00c15b1 feat(mcp): add kon-mcp — read-only MCP stdio server over transcripts and tasks
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>
2026-04-21 07:52:12 +01:00
36efcf2320 feat(onboarding): parakeet-as-default pinned by test; FirstRunPage handles distil ids
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>
2026-04-21 07:41:22 +01:00
4561810751 feat(whisper): add Distil-Whisper Small and Large v3 as first-class models
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>
2026-04-21 07:39:03 +01:00
d1eb56fac9 feat(llm): wire Phase 3 local LLM runtime via llama-cpp-2
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>
2026-04-21 07:31:51 +01:00
34fce3cf9e feat: OpenWhispr-inspired transcription polish pass
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
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>
2026-04-19 22:39:08 +01:00
86228cd517 chore(storage): drop dictionary table (v7) + retire unused storage fns — profile_terms is sole surface 2026-04-19 21:04:13 +01:00
d8a5b9bef1 feat(storage): profile + profile_terms CRUD with Default-profile guardrails 2026-04-19 20:43:56 +01:00
3f784313aa feat(storage): migration v6 — profiles + profile_terms + Default-guard triggers; seed from dictionary 2026-04-19 20:39:33 +01:00
e5661b9111 deps(transcription): drop whisper-cpp features from transcribe-rs — whisper-rs is sole Whisper backend 2026-04-19 20:23:31 +01:00