24 Commits

Author SHA1 Message Date
55b34d8ffc docs(roadmap): revise phases 6-10 after cross-model review
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
Review flagged five issues; all addressed:

1. Phase 6 cross-platform activity detection was fragile (Wayland has
   no sanctioned global-keyboard API, macOS needs accessibility
   permission, Windows needs a message-loop hook). Rewritten as a
   frontend-owned nudge-bus hybrid: consumes in-app signals Corbie
   already produces (focus-timer state, task-completed events,
   visibility/focus), dispatches via Rust for notification + TTS.
   OS-wide activity detection deferred post-v0.1.

2. Notification plugin setup was missing entirely. Added as
   cross-cutting Phase 6 prerequisite: tauri-plugin-notification in
   Cargo.toml + package.json, ACL entries, permission-request flow,
   Windows installed-app caveat, .wav sound path instead of the
   invalid 'Default' string.

3. Phase 7 idempotency nailed down: last_fired_at +
   last_fired_local_date per rule, catch_up_on_resume toggle for
   sleep/resume, task-completed event bridge spec'd, skip-counts-as-
   finish decision for morning triage, surface-action semantic
   clarified (specific task by id, not 'inbox').

4. Phase 8 grace days dropped — without streaks they were solving a
   problem that doesn't exist. Replaced with an optional non-punitive
   recent-momentum sparkline.

5. Phase 10 split into 10a (QC), 10b (rename sweep), 10c (release).
   Pre-10 Cargo.lock decision added as gating item. Removed the
   no-op 'bump to 0.1.0' step — version already matches across
   package.json, Cargo.toml, tauri.conf.json.

Totals adjusted: 8 – 13 days (was 9 – 13).
2026-04-24 18:03:21 +01:00
3cf3e41899 feat(rituals): Phase 5 — morning triage, evening wind-down, autostart
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
Three opt-in rituals, all default OFF. Research-anchored (Barkley's
point-of-performance, Sweller cognitive-load theory, Newport shutdown
ritual, Gollwitzer implementation intentions, Thaler/Sunstein nudge
with informed consent for the ADHD audience).

Morning triage: modal gated on ritualsMorning toggle, configurable
trigger time (default 08:00 to respect ADHD sleep inertia rather than
the spec's 06:00), "pick up to three for today" with a gentle swap
message on the fourth attempt. Skip sets last-shown-today so it never
re-prompts the same calendar day. last-shown persists via kon_storage.

Evening wind-down: dedicated page, user-triggered only (tray menu +
Settings button). Mechanical closure + physical reset + intentional
cue — the whole Newport template. Open loops are read-only reflection;
Tasks page owns transactions. Copy is additive throughout: "you
finished X today", never "you didn't finish Y".

Autostart: tauri-plugin-autostart registered (LaunchAgent on macOS,
.desktop on Linux, registry Run on Windows). No bespoke Rust commands
— frontend calls the plugin's invoke-handlers directly. Toggle in
Settings is one-way (click → OS call → state update) to avoid the UI
lying during the round-trip. First-run presents a forced-choice prompt
for all three options, with "skip all" escape hatches per step.

Copy audit against RSD literature: no "overdue", "failed", or
day-to-day comparison framing anywhere in ritual surfaces.

Post-v0.1 ideas captured in the roadmap: calendar integration
(read-only ICS as interim, cloud sync parked) and right-click-to-task
(in-app simple, system-wide a separate phase).
2026-04-24 17:48:01 +01:00
9f53702c7e feat(tts): Phase 4 — Read Page Aloud with OS-native voices
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
Platform-dispatched TTS (spd-say + espeak-ng fallback on Linux, say on
macOS, PowerShell System.Speech on Windows) with a shared SpeakerButton
component. Tap to speak, tap again to stop; only one button speaks at
a time so two surfaces don't talk over each other. Text always travels
via argv (or a PowerShell here-string delivered through -EncodedCommand
on Windows) so user content never enters a shell string.

Mount points: DictationPage transcript footer, transcript viewer header,
per-step in MicroSteps. Settings gains a "Read aloud" accordion with
voice picker (lazy-loaded from the OS synth), rate slider 0.5-2.0x,
and a British-English test utterance.

Rust tests cover rate mapping, NaN handling, and Windows here-string
terminator safety. No pause/resume, no SSML, no cloud voices — that
stays out of scope per the Layer-1 roadmap.
2026-04-24 16:01:47 +01:00
b344e8a580 fix(a11y): Phase 3 follow-up — implement ARIA radio-group keyboard pattern for energy selector
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Codex post-implementation review flagged one MAJOR: the energy segmented
control declared `role="radiogroup"` / `role="radio"` but only wired
`onclick`. No arrow-key navigation, no Home/End, no roving tabindex.
Keyboard users got four independent tab stops while assistive tech was
told it was a single radio group — a broken ARIA contract.

Fix (W3C APG Radio Group pattern):
- Extract the options list as `energyOptions` so the render loop and
  the keyboard handler share one source of truth.
- `energyRadioKeydown` handles ArrowLeft/Right/Up/Down (cycle wraps),
  Home (first), End (last).
- Roving tabindex: the currently-checked button gets `tabindex=0`,
  the rest get `tabindex=-1`, matching the APG recipe. Focus moves
  with selection.
- The radiogroup container gets `tabindex="-1"` to satisfy the
  svelte-check a11y rule without creating its own tab stop.

All green: 251 tests, clippy -D warnings, fmt, svelte-check 0/0, build.
2026-04-24 14:58:50 +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
a327f4d882 docs(handover): note CI red-state + Cargo.lock policy decision as open TODO 2026-04-24 14:17:56 +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
f25f8db818 feat(focus-timer): integrate with float window + add pop-out button
Jake's feedback on Phase 1: make the timer pinnable / always-on-top,
combined with the existing Now-list pop-out. Two changes:

1. Mount <FocusTimer /> in src/routes/float/+layout@.svelte so the
   running countdown stays visible in the always-on-top float window
   alongside the WIP task list. No content change to the float page
   itself — the timer is a global overlay.

2. Add a pop-out icon to the main-window focus timer that opens the
   existing /float route via window.open. One click → timer + Now
   list pinned on top without touching main window focus. Hidden
   inside the float window itself (detected via URL) so you cannot
   recursively pop out.

Result matches the Todo float-out UX the user already knows:
click ExternalLink, you get a small always-on-top window with
tasks + a live countdown ring in the top-right.
2026-04-24 12:06:37 +01:00
bbc7c217be docs(roadmap): archive Phase 1 focus-timer screenshot (sent to Jake)
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 12:00:51 +01:00
0c34a29367 feat(focus-timer): Phase 1 — visual countdown ring + just-start timer
Closes the Core MVP gap in docs/brief/feature-set.md ("visual time
representation") and wires the dangling kon:start-timer emit that
MicroSteps.svelte has been firing into the void since the stub was
written. Implements phase 1 of the 2026-04-23 feature-complete
roadmap.

New:
- src/lib/stores/focusTimer.svelte.ts — singleton timer store with
  localStorage persistence so a timer started in Dictation survives
  page nav, window close, and reopen. Gentle WebAudio chime at
  completion (no bundled asset). 250 ms tick. Completion flash for
  3 s before auto-clear.
- src/lib/components/FocusTimer.svelte — floating top-right overlay
  with SVG progress ring (shrinking colour: accent -> warning in
  the final 15% -> success on completion). Cancel + "+1 min" on
  hover. Renders nothing when idle.

Wired in +layout.svelte next to ToastViewport.

Two triggers now in the app:
- MicroSteps row 2-min button (pre-existing emit, previously no
  listener)
- WipTaskList row 5-min button (new; the brief's "just-start"
  from the Now column)

Respects prefers-reduced-motion via the existing
[data-reduce-motion] attribute.

Out of scope, carried to later phases: rhythmic voice anchoring
(Phase 6), custom-duration picker, multi-timer UI, native OS
notification (deferred to Phase 6 with the full nudge pipeline).
2026-04-24 11:50:45 +01:00
df6b19834d docs(roadmap): feature-complete plan for v0.1, 10 phases from visual-countdown through Corbie rename + release 2026-04-24 11:43:48 +01:00
420da679f9 docs(handover): post-consolidation follow-up — npm audit triaged, clippy now zero, design-system WIP abandoned
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 11:33:12 +01:00
2b82b9be5b refactor(live): rewrite needless_range_loop in duplicate-window merge with slice iterator 2026-04-24 10:59:31 +01:00
0e18a78fae chore(deps): bump @sveltejs/kit 2.57.1 -> 2.58.0 and adapter-static 3.0.6 -> 3.0.10 2026-04-24 10:58:17 +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
4e947dec21 docs: 2026-04-23 handover + test count refresh (136 -> 245) 2026-04-24 09:52:23 +01:00
509b983c09 chore(deps-dev): bump vite (dependabot, npm_and_yarn group) 2026-04-24 09:44:13 +01:00
0b1c492edd chore(deps-dev): bump @sveltejs/kit (dependabot) 2026-04-24 09:44:13 +01:00
6579c5fb6a chore(deps-dev): bump picomatch (dependabot) 2026-04-24 09:44:13 +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
dependabot[bot]
f8c9769e04 chore(deps-dev): bump picomatch
Bumps the npm_and_yarn group with 1 update in the / directory: [picomatch](https://github.com/micromatch/picomatch).


Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 15:10:24 +00:00
dependabot[bot]
becbf69c35 chore(deps-dev): bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.4.1 to 6.4.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 15:10:23 +00:00
dependabot[bot]
b8b953dfa8 chore(deps-dev): bump @sveltejs/kit
Bumps the npm_and_yarn group with 1 update in the / directory: [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit).


Updates `@sveltejs/kit` from 2.55.0 to 2.57.1
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.57.1/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.57.1
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 15:10:15 +00:00
54 changed files with 4088 additions and 186 deletions

97
HANDOVER-2026-04-19.md Normal file
View File

@@ -0,0 +1,97 @@
---
name: handover-2026-04-19
type: reference
tags: [handover, session, kon]
description: Session handover — 2026/04/19 dogfood polish + cross-platform window chrome
---
# Kon Handover — 2026/04/19
Second dogfood sprint. Four phases: (1) fix bugs surfaced on first real use, (2) redesign History for cognitive-load hygiene, (3) resolve broken window resize/drag on Linux Wayland, (4) clean up microphone picker.
## What shipped this session
### Cross-window preferences sync
- `preferences.svelte.js` emits `kon:preferences-changed` Tauri event on update.
- Main / viewer / float layouts listen and call `applyExternalPreferences` without re-emit, so theme and font changes propagate live across sibling windows.
- Echo suppressed via source window label check.
### Hotkey recorder
- Root cause of "can't change hotkey": button-level `onkeydown` relied on post-click keyboard focus, which webkit2gtk on Linux does not guarantee.
- Fix: `document.addEventListener("keydown", ..., { capture: true })` inside a `$effect` gated by `recording`. Beats any descendant handler. Escape now cancels.
### History page redesign (research-backed)
- Compact row now shows the **title** (or "Untitled"), not body-preview text — metadata already lives in the row columns (date, duration, source icon).
- Expanded row gets an inline title input (replaces the old Rename prompt modal).
- **Edit** button opens the viewer window in `edit` mode (editable textarea, debounced save to localStorage + storage-event sync back to main history).
- **Export .md** copies a full YAML-frontmatter markdown document to the clipboard — paste into Obsidian.
- **Tags**: `$lib/utils/frontmatter.js` exposes `deriveAutoTags` (currently returns `[]`), `buildFrontmatter`, `serialiseFrontmatter`, `buildMarkdown`. Manual tags stored as `item.manualTags`, rendered as removable chips in the expanded row with `+ add tag` input.
- Header tag chip bar (cap 7, click to filter, × to clear), plus `tag:xyz` search syntax.
- Global **Starred** filter toggle in the History header.
- Research memo found all five previous auto-tag families redundant with existing row UI — kept the derivation hook for the post-Task-7 `topic:*` content tag from kon-llm.
- Duplicate-transcript render fix: expanded `<p>` only if compact preview actually truncated.
### Viewer / editor popout
- `/viewer` route now reads `kon_viewer_mode` from localStorage ("view" | "edit").
- Edit mode renders a plain textarea bound to `item.text`; 400ms debounced save flushes on input, final flush on `onDestroy`. Segment-specific controls (Compact, Starred) hidden in edit mode.
- Native title: **"Kon - Transcription Editor"**.
### Platform-aware window chrome (Linux fix)
**Root cause:** Tauri v2 frameless `decorations: false` on KDE Wayland + webkit2gtk does not honour diagonal corner resize (collapses `NorthEast` etc. to a single axis via GTK's `gtk_window_begin_resize_drag`), and `data-tauri-drag-region` adds noticeable drag latency. Setting `setPointerCapture` ahead of `startResizeDragging` does not help once the compositor has taken over the pointer grab. Verified via Context7 docs + Codex diagnosis — Linux frameless is a known-fragile path.
**Fix:**
- Linux uses **native KWin/Mutter decorations**. `src-tauri/tauri.linux.conf.json` overlays `decorations: true` + full main window config (title, sizes) — overlays **replace** the windows array, so every field must be present, not just the delta. `src-tauri/src/commands/windows.rs` uses `cfg!(target_os = "linux")` to set decorations per window.
- macOS / Windows keep custom chrome. `src/lib/utils/osInfo.js` `isLinux()` gates `<Titlebar>` and `<ResizeHandles>` via `useCustomChrome = $state(false)`; flips to `!isLinux()` after `loadOsInfo()` resolves.
- Dueling drag-region handlers removed across Titlebar, float page, viewer page — everywhere a manual `startDragging()` lives, the `data-tauri-drag-region` attribute was deleted (they're alternatives per Tauri docs, not combinable).
- `ResizeHandles` kept for macOS/Windows frameless: 12 px edges / 20 px corners via CSS vars (`--kon-resize-edge`, `--kon-resize-corner`), `pointerdown` + `setPointerCapture`, corners with explicit higher z-index. Handles rendered as siblings of the animated layout div so `position: fixed` is viewport-relative rather than captured by the transform containing block.
### Window minimum sizes (evidence-backed)
Research pass cited GNOME HIG (1024×600 desktop / 360×294 mobile floors), WCAG 2.2 SC 1.4.10 Reflow (320 CSS px), Raycast 750×474 as a reference for single-pane working width, and consistent A11y principle that nothing should clip in the default configuration.
| Window | Was | Now | Rationale |
|---|---|---|---|
| Main | 1020×540 | **960×600** | Fits 210 px sidebar + ~750 px content; GNOME vertical floor. |
| Float | 400×400 | **360×480** | 360 = GNOME mobile floor; 480 fits pills + quick-add + sort + ~6 task rows without scroll. |
| Transcript editor | 450×500 | **560×520** | Exceeds WCAG reflow floor; ~60-70 char measure for editing. |
### Microphone picker cleanup
- ALSA enumeration was leaking `hw:`, `plughw:`, `front:`, `sysdefault:`, `null` et al into the dropdown.
- `SettingsPage.svelte` now renders only sentinel devices (`default`, `pipewire`, `pulse`) + one entry per unique sound card, keyed off the `sysdefault:CARD=X` alias.
- `crates/audio/src/capture.rs` reads `/proc/asound/cards` and populates a new `description` field on `DeviceInfo` with the card's full product string (e.g. "Blue Microphones" for Jake's Yeti). Frontend prefers description → CARD=X short name → raw name.
### GPU reporting
- `commands/models.rs::get_runtime_capabilities` was hardcoded to `accelerators: vec!["cpu"]` and `supports_gpu: false` for whisper. Updated to `["cpu", "vulkan"]` and whisper `supports_gpu: true`, reflecting that `crates/transcription/Cargo.toml` links transcribe-rs with the `whisper-vulkan` feature unconditionally.
- Settings now shows the Vulkan option instead of the "This build is CPU-only" notice.
### Desktop shortcut
- `~/Desktop/Kon.desktop` launcher with the 128×128 icon, `Terminal=true` so logs are visible and Ctrl+C cleanly stops the run.sh wrapper.
## What's deferred
- **Transparent windows (`transparent: true`)** — Tauri issue #13270 reports this smooths drag/resize further on Linux, but it's moot now that Linux uses native decorations.
- **File-system export (.md save dialog)** — currently clipboard-only. Needs a Rust `write_text_file` command for plugin-less file writes.
- **Bulk select + bulk export** in History.
- **LLM-powered content tags** (`topic:*`, `intent:*`) — slots into Task 7 `kon-llm` stub once Phase 3 wires real llama-cpp-2.
- **Settings UX overhaul** — Jake flagged that current settings feel overwhelming. Proposed: bunch high-traffic settings, hide advanced behind a toggle. Brainstorm + plan deferred to a dedicated session.
- **Task 7 (MicroSteps end-to-end)** — storage + Tauri CRUD + kon-llm stub + frontend dual-write all landed in an earlier commit chain. The MicroSteps UI was written as the final task 7 step but not yet dogfooded against the stub LLM. Needs manual walkthrough.
## Gotchas discovered today
| Issue | Fix |
|---|---|
| `tauri.linux.conf.json` stripped title and min sizes from main window | Overlay **replaces** the windows array — include every field, not just the delta |
| `data-tauri-drag-region` + manual `startDragging()` on the same node caused drag latency | Pick one — we use manual `startDragging` for the button/input early-return logic |
| Corner resize collapsed to single axis on KWin Wayland | Native decorations on Linux side-step the whole frameless path |
| `animate-float-enter` on the viewer/float layout root created a containing block that broke `position: fixed` on ResizeHandles children | Render ResizeHandles as a sibling of the animated div, not a descendant |
| Kon binary auto-respawned on file-save while a second run.sh was also launching → two visible instances sharing one Vite server | Do not script `./run.sh` while the user has already launched via the desktop icon; rely on HMR |
| `run.sh` leaves `"beforeDevCommand": ""` in tauri.conf.json if its cleanup trap is bypassed (e.g. SIGKILL) | Cleanup trap restores `"npm run dev"` on graceful exit; SIGTERM (not SIGKILL) is the right kill signal |
| `/proc/asound/cards` header lines have leading whitespace for 2-digit card ID alignment | Parser trims leading whitespace before checking for leading digit |
## How to resume
```
Picking up Kon dogfooding from 2026/04/19.
HANDOVER is at HANDOVER.md in the project root.
Active priorities: (1) confirm resize/drag/mic cleanup, (2) Task 7 MicroSteps
dogfood with kon-llm stub, (3) Settings UX brainstorm.
```

View File

@@ -1,97 +1,100 @@
---
name: handover-2026-04-19
name: handover-2026-04-23
type: reference
tags: [handover, session, kon]
description: Session handover — 2026/04/19 dogfood polish + cross-platform window chrome
tags: [handover, session, kon, consolidation]
description: Session handover — 2026/04/23 branch consolidation + main-in-its-best-state pass
---
# Kon Handover — 2026/04/19
# Kon Handover — 2026/04/23
Second dogfood sprint. Four phases: (1) fix bugs surfaced on first real use, (2) redesign History for cognitive-load hygiene, (3) resolve broken window resize/drag on Linux Wayland, (4) clean up microphone picker.
Consolidation session. Jake asked for "all branches to main and the repo in its best state" ahead of Corbie rebrand. This session brought outstanding Dependabot PRs into `main`, cleaned workspace lints, recovered an orphan stash onto a dedicated branch, and established a clean baseline for the post-rebrand push.
## Rebrand note
The product is in the process of being renamed **Kon → Corbie**. As of 2026/04/23 the canonical product name in any new copy (grant applications, landing pages) is **Corbie** (sentence case). The GitHub repo `jakejars/kon` and the Gitea repo `jake/kon` both still carry the `kon` name, intentionally — repo rename is pending Jake's own hand and is not blocking the rebrand copy-wise. Code paths (`crates/`, `src-tauri/`, package name `kon@0.1.0`) remain as-is until the repo rename lands; renaming the codebase identifiers is a separate coordinated sweep. See `memory/project_corbie_rebrand.md` in CORBEL-Main for the full rebrand state.
## What shipped this session
### Cross-window preferences sync
- `preferences.svelte.js` emits `kon:preferences-changed` Tauri event on update.
- Main / viewer / float layouts listen and call `applyExternalPreferences` without re-emit, so theme and font changes propagate live across sibling windows.
- Echo suppressed via source window label check.
### Baseline validation
- Fresh `cargo build`, `cargo test --workspace`, `cargo fmt --check`, `npm run check`, `npm run build` run against the pre-consolidation tip (`9b0067b`, tagged as `pre-consolidation-2026-04-23` for recovery).
- `npm run build` initially failed with a missing-dependency error for `@chenglou/pretext`. Root cause: stale partial `node_modules` install dated 2026-04-21 07:45 — the directory for the package existed but was empty. `npm install` recovered it; package is present in lockfile and needed by `src/lib/utils/textMeasure.ts`.
- `cargo clippy -- -D warnings` initially failed with 3 errors in `crates/storage/src/file_storage.rs` (two doc-list overindentations, one needless return). These were trivial style issues that clippy had never enforced at CI strictness.
### Hotkey recorder
- Root cause of "can't change hotkey": button-level `onkeydown` relied on post-click keyboard focus, which webkit2gtk on Linux does not guarantee.
- Fix: `document.addEventListener("keydown", ..., { capture: true })` inside a `$effect` gated by `recording`. Beats any descendant handler. Escape now cancels.
### Workspace clippy cleanup (commit `fe61661`)
- Applied `cargo clippy --fix` across 11 files: `crates/audio`, `crates/hotkey`, `crates/storage`, `crates/transcription`, `src-tauri`.
- Net -2 lines. No behavioural changes — pure lint cleanup (needless returns, unnecessary casts, `iter().any()``contains()`, `repeat().take()``repeat_n`, lifetime elision, `map_or` simplification, reference-immediately-dereferenced).
- One remaining warning left untouched: `needless_range_loop` at `src-tauri/src/commands/live.rs:1089` — clippy's suggested iterator rewrite would make it less readable. Earmarked for a focused refactor session.
- Build + 245 workspace tests remain green post-fix.
### History page redesign (research-backed)
- Compact row now shows the **title** (or "Untitled"), not body-preview text — metadata already lives in the row columns (date, duration, source icon).
- Expanded row gets an inline title input (replaces the old Rename prompt modal).
- **Edit** button opens the viewer window in `edit` mode (editable textarea, debounced save to localStorage + storage-event sync back to main history).
- **Export .md** copies a full YAML-frontmatter markdown document to the clipboard — paste into Obsidian.
- **Tags**: `$lib/utils/frontmatter.js` exposes `deriveAutoTags` (currently returns `[]`), `buildFrontmatter`, `serialiseFrontmatter`, `buildMarkdown`. Manual tags stored as `item.manualTags`, rendered as removable chips in the expanded row with `+ add tag` input.
- Header tag chip bar (cap 7, click to filter, × to clear), plus `tag:xyz` search syntax.
- Global **Starred** filter toggle in the History header.
- Research memo found all five previous auto-tag families redundant with existing row UI — kept the derivation hook for the post-Task-7 `topic:*` content tag from kon-llm.
- Duplicate-transcript render fix: expanded `<p>` only if compact preview actually truncated.
### Dependabot merges (commits `6579c5f`, `0b1c492`, `509b983`)
Three dev-dep bumps landed as `--no-ff` merge commits:
### Viewer / editor popout
- `/viewer` route now reads `kon_viewer_mode` from localStorage ("view" | "edit").
- Edit mode renders a plain textarea bound to `item.text`; 400ms debounced save flushes on input, final flush on `onDestroy`. Segment-specific controls (Compact, Starred) hidden in edit mode.
- Native title: **"Kon - Transcription Editor"**.
| # | Package | File changes |
|---|---|---|
| 1 | `picomatch` | `package-lock.json` only |
| 2 | `@sveltejs/kit` | `package.json` + `package-lock.json` |
| 3 | `vite` (npm_and_yarn group) | `package.json` + `package-lock.json` |
### Platform-aware window chrome (Linux fix)
**Root cause:** Tauri v2 frameless `decorations: false` on KDE Wayland + webkit2gtk does not honour diagonal corner resize (collapses `NorthEast` etc. to a single axis via GTK's `gtk_window_begin_resize_drag`), and `data-tauri-drag-region` adds noticeable drag latency. Setting `setPointerCapture` ahead of `startResizeDragging` does not help once the compositor has taken over the pointer grab. Verified via Context7 docs + Codex diagnosis — Linux frameless is a known-fragile path.
After the three merges: `npm install` clean, `npm run build` green, `npm run check` 0 errors / 0 warnings. Vulnerability count went from 6 (1 low / 2 moderate / 3 high) to 5 (3 low / 2 moderate) — the three highs cleared.
**Fix:**
- Linux uses **native KWin/Mutter decorations**. `src-tauri/tauri.linux.conf.json` overlays `decorations: true` + full main window config (title, sizes) — overlays **replace** the windows array, so every field must be present, not just the delta. `src-tauri/src/commands/windows.rs` uses `cfg!(target_os = "linux")` to set decorations per window.
- macOS / Windows keep custom chrome. `src/lib/utils/osInfo.js` `isLinux()` gates `<Titlebar>` and `<ResizeHandles>` via `useCustomChrome = $state(false)`; flips to `!isLinux()` after `loadOsInfo()` resolves.
- Dueling drag-region handlers removed across Titlebar, float page, viewer page — everywhere a manual `startDragging()` lives, the `data-tauri-drag-region` attribute was deleted (they're alternatives per Tauri docs, not combinable).
- `ResizeHandles` kept for macOS/Windows frameless: 12 px edges / 20 px corners via CSS vars (`--kon-resize-edge`, `--kon-resize-corner`), `pointerdown` + `setPointerCapture`, corners with explicit higher z-index. Handles rendered as siblings of the animated layout div so `position: fixed` is viewport-relative rather than captured by the transform containing block.
### Orphan-stash recovery (branch `feat/design-system-recover`, commit `8855db8`)
- A WIP stash sat on `stash@{0}`, pinned to parent commit `1296173` — the tip of the deleted `feat/design-system` branch. ~1000 LOC across 24 files: `ai-formatting/rule_based.rs` rewrite, `transcription/local_engine.rs`, core `hardware.rs` + `recommendation.rs`, `audio/resample.rs`, and a sweep across Svelte UI (`TaskSidebar`, `FilesPage`, `TasksPage`, `WipTaskList`, `ModelDownloader`, `Titlebar`, `viewer`, `float`).
- Recovered using `git stash branch`, which creates a branch from the stash's parent commit and applies the stash content. Parent commit still existed in the reflog even though no branch referenced it.
- Committed the full recovery as `wip(design-system): recover orphan stash` on the new branch. **Not merged to main.** Needs a focused rebase session: heavy conflicts expected in `TasksPage`, `FilesPage`, `ai-formatting/rule_based.rs`, and `transcription/local_engine.rs` against current main since those all saw release-blocker fixes after the stash was taken. Also missing the 2026-04-23 clippy cleanup.
- Branch pushed to `github` for remote preservation.
### Window minimum sizes (evidence-backed)
Research pass cited GNOME HIG (1024×600 desktop / 360×294 mobile floors), WCAG 2.2 SC 1.4.10 Reflow (320 CSS px), Raycast 750×474 as a reference for single-pane working width, and consistent A11y principle that nothing should clip in the default configuration.
## Release-blocker state (unchanged this session)
| Window | Was | Now | Rationale |
|---|---|---|---|
| Main | 1020×540 | **960×600** | Fits 210 px sidebar + ~750 px content; GNOME vertical floor. |
| Float | 400×400 | **360×480** | 360 = GNOME mobile floor; 480 fits pills + quick-add + sort + ~6 task rows without scroll. |
| Transcript editor | 450×500 | **560×520** | Exceeds WCAG reflow floor; ~60-70 char measure for editing. |
From `docs/issues/README.md`:
### Microphone picker cleanup
- ALSA enumeration was leaking `hw:`, `plughw:`, `front:`, `sysdefault:`, `null` et al into the dropdown.
- `SettingsPage.svelte` now renders only sentinel devices (`default`, `pipewire`, `pulse`) + one entry per unique sound card, keyed off the `sysdefault:CARD=X` alias.
- `crates/audio/src/capture.rs` reads `/proc/asound/cards` and populates a new `description` field on `DeviceInfo` with the card's full product string (e.g. "Blue Microphones" for Jake's Yeti). Frontend prefers description → CARD=X short name → raw name.
- **0 open CRITICAL**
- **1 open MAJOR** — RB-08 `power-assertion-macos-objc2` (awaits manual runtime verification on a real macOS machine: `pmset -g assertions` during a background live-session)
- **11 RBs resolved**
- **11 CR items resolved** from the 2026-04-22 code review
### GPU reporting
- `commands/models.rs::get_runtime_capabilities` was hardcoded to `accelerators: vec!["cpu"]` and `supports_gpu: false` for whisper. Updated to `["cpu", "vulkan"]` and whisper `supports_gpu: true`, reflecting that `crates/transcription/Cargo.toml` links transcribe-rs with the `whisper-vulkan` feature unconditionally.
- Settings now shows the Vulkan option instead of the "This build is CPU-only" notice.
RB-08 continues to gate v0.1 tagging.
### Desktop shortcut
- `~/Desktop/Kon.desktop` launcher with the 128×128 icon, `Terminal=true` so logs are visible and Ctrl+C cleanly stops the run.sh wrapper.
## Post-consolidation follow-up (evening 2026/04/23)
## What's deferred
Jake asked for "nothing outstanding" on Corbie. Follow-up pass did:
- **Transparent windows (`transparent: true`)** — Tauri issue #13270 reports this smooths drag/resize further on Linux, but it's moot now that Linux uses native decorations.
- **File-system export (.md save dialog)** — currently clipboard-only. Needs a Rust `write_text_file` command for plugin-less file writes.
- **Bulk select + bulk export** in History.
- **LLM-powered content tags** (`topic:*`, `intent:*`) — slots into Task 7 `kon-llm` stub once Phase 3 wires real llama-cpp-2.
- **Settings UX overhaul** — Jake flagged that current settings feel overwhelming. Proposed: bunch high-traffic settings, hide advanced behind a toggle. Brainstorm + plan deferred to a dedicated session.
- **Task 7 (MicroSteps end-to-end)** — storage + Tauri CRUD + kon-llm stub + frontend dual-write all landed in an earlier commit chain. The MicroSteps UI was written as the final task 7 step but not yet dogfooded against the stub LLM. Needs manual walkthrough.
### 1. npm audit triaged + safe minor bumps landed
- `@sveltejs/kit` 2.57.1 → **2.58.0**, `@sveltejs/adapter-static` 3.0.6 → **3.0.10** (commit `0e18a78`). Both are patch / minor SemVer — no API breakage.
- **Residual advisories accepted** (5 total: 3 low / 2 moderate, all transitive, not session-actionable):
- **cookie@0.6.x** (3× low) pinned by `@sveltejs/kit@^0.6.0` as of kit 2.58.0. Upstream has not bumped the pin; fixed cookie version is `0.7+`. Advisory: out-of-bounds chars in name/path/domain. **Context**: Kon/Corbie is a Tauri desktop app, no public-facing HTTP server, so the attack surface for this is effectively nil. Re-triage when `@sveltejs/kit` lands a cookie bump.
- **esbuild via svelte-i18n@4.0.1** (2× moderate) — `svelte-i18n` bundles `esbuild@^0.19.2` for its CLI. Advisory: "dev-server allows any website to read responses". **Context**: only affects `vite dev` over the network. In a Tauri desktop app the dev server is localhost-only and not relevant to production. `svelte-i18n` is already at latest (4.0.1); the only way to drop this is replace the i18n library.
- `npm audit --audit-level high` would report 0 vulnerabilities; GitHub's richer advisory db may still show the transitive set but none are actionable without upstream bumps.
## Gotchas discovered today
### 2. needless_range_loop refactor landed
Commit `2b82b9b``src-tauri/src/commands/live.rs:1087` duplicate-merge inner loop rewritten as `for segment in &nearby[start..upper]`. Workspace now **zero clippy warnings** with `cargo clippy --workspace --all-targets`. Safe to turn on `-D warnings` in CI when desired.
| Issue | Fix |
|---|---|
| `tauri.linux.conf.json` stripped title and min sizes from main window | Overlay **replaces** the windows array — include every field, not just the delta |
| `data-tauri-drag-region` + manual `startDragging()` on the same node caused drag latency | Pick one — we use manual `startDragging` for the button/input early-return logic |
| Corner resize collapsed to single axis on KWin Wayland | Native decorations on Linux side-step the whole frameless path |
| `animate-float-enter` on the viewer/float layout root created a containing block that broke `position: fixed` on ResizeHandles children | Render ResizeHandles as a sibling of the animated div, not a descendant |
| Kon binary auto-respawned on file-save while a second run.sh was also launching → two visible instances sharing one Vite server | Do not script `./run.sh` while the user has already launched via the desktop icon; rely on HMR |
| `run.sh` leaves `"beforeDevCommand": ""` in tauri.conf.json if its cleanup trap is bypassed (e.g. SIGKILL) | Cleanup trap restores `"npm run dev"` on graceful exit; SIGTERM (not SIGKILL) is the right kill signal |
| `/proc/asound/cards` header lines have leading whitespace for 2-digit card ID alignment | Parser trims leading whitespace before checking for leading digit |
### 3. `feat/design-system-recover` evaluated, WIP layer abandoned
Compared the branch against current main:
- The handoff commit's content (design tokens, fonts, previews, ui_kits) is **already on main** as commit `8ba5641`. Recovery branch just duplicates it from a different base.
- The WIP layer on top modifies 24 files (TasksPage, FilesPage, WipTaskList, TaskSidebar, viewer, float, local_engine, rule_based, etc.) against a base main has since significantly refactored (RB-01 through RB-12 + all the CR-2026-04-22 fixes + a preferences.svelte.js → .ts conversion).
- Test merge surfaces **14+ conflicts** needing per-file, designer-intent-aware resolution. Conflict cost > value of the WIP given main has already moved past most of it.
- **Decision: abandon the WIP**. Local branch deleted. `github/feat/design-system-recover` preserved as a read-only archive for reference mining. Any design-system polish from here gets redone fresh against current main rather than rebased through a stale WIP.
## How to resume
## Still deferred (not session-actionable)
```
Picking up Kon dogfooding from 2026/04/19.
HANDOVER is at HANDOVER.md in the project root.
Active priorities: (1) confirm resize/drag/mic cleanup, (2) Task 7 MicroSteps
dogfood with kon-llm stub, (3) Settings UX brainstorm.
```
- **CI green-up + Cargo.lock policy decision** — four jobs on the 2026-04-24 evening push (three `cargo check` matrix legs + `svelte build + lint`) went red, but a clean reproduction locally (`git archive HEAD | tar -x`, fresh `npm ci`, `cargo check --workspace --all-targets`) is green. Root cause likely one of: (a) crate version drift because `Cargo.lock` is in `.gitignore` so CI resolves fresh every run (a binary workspace should normally commit its lockfile), (b) Swatinem/rust-cache bad entry, (c) transient CI-runner environment issue. Next steps before restarting CI: pull the actual failing-step log text from the GitHub Actions UI, and decide whether to start committing `Cargo.lock` (recommended for a Tauri binary).
- **RB-08 verification** — needs macOS hardware access. Gates v0.1 tag. One concrete open item.
- **Kon → Corbie codebase rename** — Jake handles repo rename (Gitea + GitHub); coordinated codebase sweep (package name `kon@0.1.0``corbie@0.1.0`, crate prefixes `kon-*``corbie-*`, desktop file, binary name, install paths `~/.local/share/kon/``~/.local/share/corbie/`, database filename `kon.db`, window titles, README body) should follow the repo rename so artefact names match. Will need a migration shim to keep existing users' data alive across the path change.
- **npm audit residuals** — see §1 above. Re-visit when `@sveltejs/kit` bumps its cookie pin, or when an i18n replacement is chosen.
- **CI clippy enforcement** — workspace is now clean with default clippy. Turning on `-D warnings` in CI costs nothing additional.
- **Transparent windows**, **file-system export**, **bulk select/export**, **LLM content tags**, **Settings UX overhaul**, **Task 7 walk-through** — all carried from 2026-04-19 handover.
## Repo state at end of follow-up
- `main` at `2b82b9b` (evening pass: +3 commits — sveltejs bumps, clippy refactor, this handover update)
- Local branches: `main` only (recovery branch deleted locally after archival)
- `github/feat/design-system-recover` at `8855db8` (preserved as archive)
- Tag `pre-consolidation-2026-04-23` at `9b0067b`
- `cargo build --workspace` ✓ / `cargo test --workspace` ✓ (245 passing) / `cargo clippy --workspace --all-targets` **0 warnings** / `cargo fmt --check` ✓ / `npm run check` ✓ (0 errors, 0 warnings) / `npm run build`
## Anchors
- Previous handover: [HANDOVER-2026-04-19.md](HANDOVER-2026-04-19.md)
- Release-blocker index: [docs/issues/README.md](docs/issues/README.md)
- Rebrand memory: `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_corbie_rebrand.md`
- Active-focus upstream: `context/active-focus.md` in CORBEL-Main

View File

@@ -11,7 +11,7 @@ Kon is a local-first, cognitive-load-aware dictation and task-capture desktop ap
**Pre-alpha.** Actively dogfooded on Linux (KDE Plasma 6 on Wayland). macOS and Windows targets are in scope and exercised by CI, but not yet beta-ready. One primary user; open source-intent with licence TBD before public beta.
- Current `main`: see commit log
- 136 automated lib tests across 10 crates, all passing
- 245 automated lib tests across 10 crates, all passing
- Cross-platform CI (Linux / macOS / Windows) via GitHub Actions
---
@@ -288,7 +288,7 @@ CI also builds release installers on tag push (see `.github/workflows/build.yml`
### Testing
```bash
cargo test --workspace --lib # 136 tests across 10 crates
cargo test --workspace --lib # 245 tests across 10 crates
npm run check # svelte-check (type-checks .svelte files)
cargo check --workspace --all-targets
```

View File

@@ -112,7 +112,7 @@ impl MicrophoneCapture {
for device in devices {
let name = device_display_name(&device).unwrap_or_else(|| "<unnamed>".to_string());
let (sample_rate, channels) = match device.default_input_config() {
Ok(cfg) => (cfg.sample_rate(), cfg.channels() as u16),
Ok(cfg) => (cfg.sample_rate(), cfg.channels()),
Err(_) => (0, 0),
};
let is_likely_monitor = is_monitor_name(&name);
@@ -277,11 +277,7 @@ fn device_display_name(device: &cpal::Device) -> Option<String> {
/// `pipewire` / `default` → `None`
fn extract_card_id(name: &str) -> Option<&str> {
let rest = name.split("CARD=").nth(1)?;
Some(
rest.split(|c: char| c == ',' || c == ';')
.next()
.unwrap_or(rest),
)
Some(rest.split([',', ';']).next().unwrap_or(rest))
}
/// Read `/proc/asound/cards` and return a map from ALSA card short name
@@ -361,7 +357,7 @@ fn open_and_validate(
.default_input_config()
.map_err(|e| KonError::AudioCaptureFailed(format!("default_input_config: {e}")))?;
let sample_rate = config.sample_rate();
let channels = config.channels() as u16;
let channels = config.channels();
let format = config.sample_format();
eprintln!(

View File

@@ -343,14 +343,14 @@ async fn device_listener(
fn is_event_device(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map_or(false, |n| n.starts_with("event"))
.is_some_and(|n| n.starts_with("event"))
}
/// Return true when the device's reported key set includes the combo's
/// configured trigger key. A device that reports no keys at all (for
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
fn device_supports_combo(supported: Option<&AttributeSetRef<Key>>, combo: &HotkeyCombo) -> bool {
supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code)))
supported.is_some_and(|keys| keys.contains(Key::new(combo.key_code)))
}
#[cfg(test)]

View File

@@ -240,11 +240,30 @@ impl LlmEngine {
}
pub fn decompose_task(&self, task_text: &str) -> Result<Vec<String>, EngineError> {
self.decompose_task_with_feedback(task_text, &[])
}
/// Same as `decompose_task` but allows callers to pass recent HITL
/// feedback rows so the system prompt gets conditioned on the
/// user's preferred decomposition style. The `examples` vec is
/// rendered into a few-shot block appended to the base system
/// prompt by `prompts::build_conditioned_system_prompt`.
///
/// Callers should pass most-recent-first; older examples still
/// participate but weigh less because of their position in the
/// prompt. Empty slice keeps behaviour identical to `decompose_task`.
pub fn decompose_task_with_feedback(
&self,
task_text: &str,
examples: &[prompts::FeedbackExample],
) -> Result<Vec<String>, EngineError> {
let model = self.loaded_model_arc()?;
let system =
prompts::build_conditioned_system_prompt(prompts::DECOMPOSE_TASK_SYSTEM, examples);
let prompt = render_chat_prompt(
&model,
&[
("system", prompts::DECOMPOSE_TASK_SYSTEM),
("system", system.as_str()),
("user", &format!("Task: {task_text}")),
],
)?;
@@ -261,15 +280,27 @@ impl LlmEngine {
}
pub fn extract_tasks(&self, transcript: &str) -> Result<Vec<String>, EngineError> {
self.extract_tasks_with_feedback(transcript, &[])
}
/// Feedback-conditioned variant of `extract_tasks`. See
/// `decompose_task_with_feedback` for the `examples` semantics.
pub fn extract_tasks_with_feedback(
&self,
transcript: &str,
examples: &[prompts::FeedbackExample],
) -> Result<Vec<String>, EngineError> {
if transcript.trim().is_empty() {
return Ok(Vec::new());
}
let model = self.loaded_model_arc()?;
let system =
prompts::build_conditioned_system_prompt(prompts::EXTRACT_TASKS_SYSTEM, examples);
let prompt = render_chat_prompt(
&model,
&[
("system", prompts::EXTRACT_TASKS_SYSTEM),
("system", system.as_str()),
("user", &format!("Transcript:\n{transcript}")),
],
)?;

View File

@@ -10,3 +10,113 @@ output a JSON array of action items the speaker committed to. Each item must \
be a short imperative sentence. Omit observations, wishes, and background \
context that are not explicit commitments. Output an empty array if there are \
no action items.";
/// Compact representation of a human-in-the-loop feedback example used
/// for few-shot prompt conditioning. Built by kon-storage and fed to the
/// prompt builder below; we keep this struct local to the LLM crate so
/// kon-llm does not depend on kon-storage.
#[derive(Debug, Clone)]
pub struct FeedbackExample {
/// What the AI was given as input (e.g. the parent task text, or
/// the transcript chunk). Kept verbatim.
pub input: String,
/// What the AI produced originally. `None` if the user only
/// gave a thumbs-up without a prior edit (positive signal
/// without a paired correction).
pub original_output: Option<String>,
/// What the user changed it to. `None` for thumbs-only rows.
/// This is the highest-value signal — when present, inject it
/// as the "good" output in the few-shot example.
pub corrected_output: Option<String>,
}
/// Render a feedback example into the exemplar block used in prompt
/// conditioning. Returns `None` for rows that carry no usable pairing
/// (e.g. a thumbs-up with no input context).
fn render_feedback_exemplar(ex: &FeedbackExample) -> Option<String> {
if ex.input.trim().is_empty() {
return None;
}
let good = ex
.corrected_output
.as_deref()
.or(ex.original_output.as_deref())?;
let good = good.trim();
if good.is_empty() {
return None;
}
Some(format!("Input: {}\nGood output: {}", ex.input.trim(), good))
}
/// Build a system prompt that combines the base task system prompt
/// with a few-shot block assembled from recent HITL examples. If no
/// usable examples are available, returns the base prompt unchanged
/// so early users see the generic behaviour and the LLM is not
/// confused by an empty exemplar section.
///
/// The exemplars are ordered most-recent-first (caller's order is
/// preserved) so the LLM weights the user's current style over
/// earlier noise, mirroring what a human reviewer would do.
pub fn build_conditioned_system_prompt(base: &str, examples: &[FeedbackExample]) -> String {
let rendered: Vec<String> = examples
.iter()
.filter_map(render_feedback_exemplar)
.collect();
if rendered.is_empty() {
return base.to_string();
}
let block = rendered
.iter()
.map(|s| format!("- {s}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"{base}\n\nHere are examples of the style this user prefers, in the \
user's own words. Match this style closely when producing your output:\n{block}"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_plain_prompt_when_no_examples() {
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &[]);
assert_eq!(out, DECOMPOSE_TASK_SYSTEM);
}
#[test]
fn skips_empty_input_examples() {
let examples = vec![FeedbackExample {
input: String::new(),
original_output: None,
corrected_output: Some("ignored".into()),
}];
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &examples);
assert_eq!(out, DECOMPOSE_TASK_SYSTEM);
}
#[test]
fn prefers_corrected_over_original() {
let examples = vec![FeedbackExample {
input: "Clean room".into(),
original_output: Some("Organise your bedroom".into()),
corrected_output: Some("Pick up one shirt from the floor".into()),
}];
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &examples);
assert!(out.contains("Pick up one shirt from the floor"));
assert!(!out.contains("Organise your bedroom"));
}
#[test]
fn falls_back_to_original_when_no_correction() {
let examples = vec![FeedbackExample {
input: "Write report".into(),
original_output: Some("Open a blank document".into()),
corrected_output: None,
}];
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &examples);
assert!(out.contains("Open a blank document"));
}
}

View File

@@ -270,6 +270,11 @@ pub async fn search_transcripts(
/// Insert a task. `list_id` and `effort` are nullable (schema predates their
/// UI surfacing); `notes` defaults to '' at the column level. Callers that
/// want to set metadata at creation time pass `Some(...)`; omit for defaults.
///
/// Positional signature is deliberately flat — it mirrors the `tasks`
/// schema columns one-to-one. Refactor to a params struct only if another
/// nullable is added after `energy`.
#[allow(clippy::too_many_arguments)]
pub async fn insert_task(
pool: &SqlitePool,
id: &str,
@@ -278,10 +283,11 @@ pub async fn insert_task(
source_transcript_id: Option<&str>,
list_id: Option<&str>,
effort: Option<&str>,
energy: Option<&str>,
) -> Result<()> {
sqlx::query(
"INSERT INTO tasks (id, text, bucket, source_transcript_id, list_id, effort) \
VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO tasks (id, text, bucket, source_transcript_id, list_id, effort, energy) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(text)
@@ -289,6 +295,7 @@ pub async fn insert_task(
.bind(source_transcript_id)
.bind(list_id)
.bind(effort)
.bind(energy)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?;
@@ -298,7 +305,7 @@ pub async fn insert_task(
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
let rows = sqlx::query(
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
source_transcript_id, parent_task_id \
source_transcript_id, parent_task_id, energy \
FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC",
)
.fetch_all(pool)
@@ -311,7 +318,7 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
let row = sqlx::query(
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
source_transcript_id, parent_task_id FROM tasks WHERE id = ?",
source_transcript_id, parent_task_id, energy FROM tasks WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
@@ -361,6 +368,27 @@ pub async fn update_task(
})
}
/// Dedicated tri-state energy setter. Exists as its own function because
/// `update_task` uses `COALESCE(?, col)` to let `None` mean "preserve" —
/// which makes it impossible to explicitly clear energy back to NULL.
/// `set_task_energy` always writes exactly the value passed, including
/// `None` to clear. Returns the refreshed row.
///
/// Caller is responsible for validating `energy` is one of the allowed
/// values; the CHECK constraint will reject anything else at commit time.
pub async fn set_task_energy(pool: &SqlitePool, id: &str, energy: Option<&str>) -> Result<TaskRow> {
sqlx::query("UPDATE tasks SET energy = ? WHERE id = ?")
.bind(energy)
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("set_task_energy failed: {e}")))?;
get_task_by_id(pool, id).await?.ok_or_else(|| {
KonError::StorageError(format!("set_task_energy: task {id} not found after update"))
})
}
pub async fn insert_subtask(
pool: &SqlitePool,
id: &str,
@@ -380,7 +408,7 @@ pub async fn insert_subtask(
pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<TaskRow>> {
let rows = sqlx::query(
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
source_transcript_id, parent_task_id \
source_transcript_id, parent_task_id, energy \
FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC",
)
.bind(parent_id)
@@ -577,6 +605,11 @@ pub struct TaskRow {
pub created_at: String,
pub source_transcript_id: Option<String>,
pub parent_task_id: Option<String>,
/// Phase 3 energy tagging: one of `"high"`, `"medium"`, `"brain_dead"`,
/// or `None`. Enforced at the DB layer via a CHECK constraint (see
/// migration v11). Unset is the expected normal case — the match-my-
/// energy sort treats unset as Medium-equivalent.
pub energy: Option<String>,
}
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
@@ -639,6 +672,7 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
created_at: r.get("created_at"),
source_transcript_id: r.get("source_transcript_id"),
parent_task_id: r.get("parent_task_id"),
energy: r.get("energy"),
}
}
@@ -889,6 +923,151 @@ pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<Err
.collect())
}
// --- Feedback (HITL) -------------------------------------------------------
//
// Phase 2 of the feature-complete roadmap: capture thumbs + corrections on
// AI-generated output so the prompt builder can inject recent examples as
// few-shot exemplars. Storage-only here; the prompt-conditioning logic lives
// in kon-llm. Retrieval returns the most recent rows, narrowed to the
// active profile when provided so feedback does not cross profiles.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeedbackTargetType {
MicroStep,
TaskExtraction,
Cleanup,
}
impl FeedbackTargetType {
pub fn as_str(self) -> &'static str {
match self {
FeedbackTargetType::MicroStep => "microstep",
FeedbackTargetType::TaskExtraction => "task_extraction",
FeedbackTargetType::Cleanup => "cleanup",
}
}
/// Parse the database `target_type` string back into the enum.
/// Named `parse` rather than `from_str` so it does not collide with
/// the `std::str::FromStr` trait — the trait is overkill here
/// because callers never want a `FromStr::Err` and already know the
/// set of valid values at the call site.
pub fn parse(s: &str) -> Option<Self> {
match s {
"microstep" => Some(FeedbackTargetType::MicroStep),
"task_extraction" => Some(FeedbackTargetType::TaskExtraction),
"cleanup" => Some(FeedbackTargetType::Cleanup),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct RecordFeedbackParams {
pub target_type: FeedbackTargetType,
pub target_id: Option<String>,
/// -1 = thumbs down, 0 = correction (neutral), +1 = thumbs up.
pub rating: i8,
pub original_text: Option<String>,
pub corrected_text: Option<String>,
pub context_json: Option<String>,
pub profile_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct FeedbackRow {
pub id: i64,
pub target_type: String,
pub target_id: Option<String>,
pub rating: i64,
pub original_text: Option<String>,
pub corrected_text: Option<String>,
pub context_json: Option<String>,
pub profile_id: String,
pub created_at: String,
}
pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result<i64> {
if !matches!(params.rating, -1..=1) {
return Err(KonError::StorageError(format!(
"invalid feedback rating {} (must be -1, 0, or 1)",
params.rating
)));
}
let profile_id = params
.profile_id
.unwrap_or_else(|| crate::DEFAULT_PROFILE_ID.to_string());
let row = sqlx::query(
"INSERT INTO feedback (
target_type, target_id, rating,
original_text, corrected_text, context_json, profile_id
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(params.target_type.as_str())
.bind(params.target_id)
.bind(params.rating as i64)
.bind(params.original_text)
.bind(params.corrected_text)
.bind(params.context_json)
.bind(profile_id)
.fetch_one(pool)
.await
.map_err(|e| KonError::StorageError(format!("record_feedback failed: {e}")))?;
Ok(row.get::<i64, _>("id"))
}
/// Fetch the most recent feedback rows for a given target type, scoped to
/// the active profile. Used by the prompt builder to gather few-shot
/// exemplars. Orders by `created_at DESC` so the most recent corrections
/// outweigh older ones — the user's style drifts, and we want the LLM
/// to track the current preference.
///
/// `min_rating` filters out thumbs-down examples when the caller only
/// wants positive reinforcement; pass `-1` to include everything.
pub async fn list_feedback_examples(
pool: &SqlitePool,
target_type: FeedbackTargetType,
limit: i64,
min_rating: i8,
profile_id: Option<&str>,
) -> Result<Vec<FeedbackRow>> {
let pid = profile_id.unwrap_or(crate::DEFAULT_PROFILE_ID);
let rows = sqlx::query(
"SELECT id, target_type, target_id, rating,
original_text, corrected_text, context_json,
profile_id, created_at
FROM feedback
WHERE target_type = ?
AND profile_id = ?
AND rating >= ?
ORDER BY created_at DESC, id DESC
LIMIT ?",
)
.bind(target_type.as_str())
.bind(pid)
.bind(min_rating as i64)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("list_feedback_examples failed: {e}")))?;
Ok(rows
.into_iter()
.map(|r| FeedbackRow {
id: r.get("id"),
target_type: r.get("target_type"),
target_id: r.get("target_id"),
rating: r.get("rating"),
original_text: r.get("original_text"),
corrected_text: r.get("corrected_text"),
context_json: r.get("context_json"),
profile_id: r.get("profile_id"),
created_at: r.get("created_at"),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1013,9 +1192,18 @@ mod tests {
async fn task_crud_roundtrip() {
let pool = test_pool().await;
insert_task(&pool, "task1", "Buy groceries", "today", None, None, None)
.await
.unwrap();
insert_task(
&pool,
"task1",
"Buy groceries",
"today",
None,
None,
None,
None,
)
.await
.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
assert_eq!(tasks.len(), 1);
@@ -1034,7 +1222,7 @@ mod tests {
#[tokio::test]
async fn subtask_crud_roundtrip() {
let pool = test_pool().await;
insert_task(&pool, "p1", "Write report", "inbox", None, None, None)
insert_task(&pool, "p1", "Write report", "inbox", None, None, None, None)
.await
.unwrap();
insert_subtask(&pool, "s1", "Open document", "p1")
@@ -1079,7 +1267,7 @@ mod tests {
// child must also reopen the parent so "parent is done iff
// every child is done" holds in both directions.
let pool = test_pool().await;
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None)
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None, None)
.await
.unwrap();
insert_subtask(&pool, "s1", "Final test", "p1")
@@ -1113,10 +1301,10 @@ mod tests {
// Sanity: tasks without a parent must not trigger the parent-
// reopen branch (it should no-op cleanly).
let pool = test_pool().await;
insert_task(&pool, "a", "A", "inbox", None, None, None)
insert_task(&pool, "a", "A", "inbox", None, None, None, None)
.await
.unwrap();
insert_task(&pool, "b", "B", "inbox", None, None, None)
insert_task(&pool, "b", "B", "inbox", None, None, None, None)
.await
.unwrap();
complete_task(&pool, "a").await.unwrap();
@@ -1240,7 +1428,7 @@ mod tests {
async fn update_task_overwrites_provided_fields() {
// Task 2.6 — happy path: insert, update bucket + effort, read back.
let pool = test_pool().await;
insert_task(&pool, "u1", "Draft post", "inbox", None, None, None)
insert_task(&pool, "u1", "Draft post", "inbox", None, None, None, None)
.await
.unwrap();
@@ -1257,9 +1445,18 @@ mod tests {
async fn update_task_partial_leaves_others_unchanged() {
// Task 2.6 — partial update: only notes changes; text/bucket intact.
let pool = test_pool().await;
insert_task(&pool, "u2", "Prep slides", "today", None, None, Some("30m"))
.await
.unwrap();
insert_task(
&pool,
"u2",
"Prep slides",
"today",
None,
None,
Some("30m"),
None,
)
.await
.unwrap();
let row = update_task(
&pool,
@@ -1288,6 +1485,52 @@ mod tests {
);
}
// --- Energy tagging (Phase 3) ---
#[tokio::test]
async fn set_task_energy_round_trip_includes_explicit_clear() {
let pool = test_pool().await;
insert_task(&pool, "e1", "Write report", "inbox", None, None, None, None)
.await
.unwrap();
// Fresh task: energy must be NULL.
let t = get_task_by_id(&pool, "e1").await.unwrap().unwrap();
assert!(t.energy.is_none(), "new task must start with energy unset");
// Set High.
let t = set_task_energy(&pool, "e1", Some("high")).await.unwrap();
assert_eq!(t.energy.as_deref(), Some("high"));
// Change to Medium.
let t = set_task_energy(&pool, "e1", Some("medium")).await.unwrap();
assert_eq!(t.energy.as_deref(), Some("medium"));
// Explicit clear back to NULL — the whole reason this function
// exists separately from update_task's COALESCE semantics.
let t = set_task_energy(&pool, "e1", None).await.unwrap();
assert!(
t.energy.is_none(),
"set_task_energy(None) must explicitly clear the column"
);
}
#[tokio::test]
async fn set_task_energy_rejects_unknown_value_via_check_constraint() {
// Migration v11 defines a CHECK constraint; invalid values must
// be rejected at the DB layer even if a caller bypasses frontend
// validation.
let pool = test_pool().await;
insert_task(&pool, "e2", "Task", "inbox", None, None, None, None)
.await
.unwrap();
let res = set_task_energy(&pool, "e2", Some("turbo")).await;
assert!(
res.is_err(),
"CHECK constraint must reject energy values outside the enum"
);
}
// --- Profile CRUD tests (Task 11) ---
#[tokio::test]

View File

@@ -2,11 +2,11 @@ use std::path::PathBuf;
/// Resolve the per-user app data directory, following each OS's convention:
///
/// - Windows: `%LOCALAPPDATA%\kon\` e.g. `C:\Users\Jake\AppData\Local\kon`
/// - Windows: `%LOCALAPPDATA%\kon\` e.g. `C:\Users\Jake\AppData\Local\kon`
/// - macOS: `~/Library/Application Support/Kon/`
/// - Linux: `$XDG_DATA_HOME/kon` or `~/.local/share/kon` (XDG Base Directory),
/// with a fallback to the legacy `~/.kon/` if it already exists, so
/// existing installs keep working.
/// with a fallback to the legacy `~/.kon/` if it already exists, so
/// existing installs keep working.
/// - Other Unix: `~/.kon/`
///
/// TODO: Consolidate with `crates/transcription/src/model_manager.rs::dirs_path()`
@@ -45,7 +45,7 @@ pub fn app_data_dir() -> PathBuf {
return PathBuf::from(xdg).join("kon");
}
}
return PathBuf::from(home).join(".local").join("share").join("kon");
PathBuf::from(home).join(".local").join("share").join("kon")
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]

View File

@@ -10,10 +10,11 @@ pub use database::{
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
create_profile, delete_profile, delete_profile_term, delete_task, delete_transcript,
get_profile, get_setting, get_task_by_id, get_transcript, init, insert_subtask, insert_task,
insert_transcript, list_profile_terms, list_profiles, list_recent_errors, list_subtasks,
list_tasks, list_transcripts, list_transcripts_paged, log_error, search_transcripts,
set_setting, uncomplete_task, update_profile, update_task, update_transcript,
update_transcript_meta, ErrorLogRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
TaskRow, TranscriptRow,
insert_transcript, list_feedback_examples, list_profile_terms, list_profiles,
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
log_error, record_feedback, search_transcripts, set_setting, set_task_energy, uncomplete_task,
update_profile, update_task, update_transcript, update_transcript_meta, ErrorLogRow,
FeedbackRow, FeedbackTargetType, InsertTranscriptParams, ProfileRow, ProfileTermRow,
RecordFeedbackParams, TaskRow, TranscriptRow,
};
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};

View File

@@ -334,6 +334,72 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
FROM transcripts;
"#,
),
(
10,
"feedback: HITL thumbs + correction capture",
r#"
-- Feedback rows capture human-in-the-loop signal on AI-generated
-- output. Two flavours bundled into one table:
-- - thumbs (rating = -1 | +1, original_text optional, corrected_text NULL)
-- - correction (rating defaults to +1, original_text + corrected_text present)
--
-- `target_type` names the producing surface:
-- 'microstep' — subtask decomposition from DECOMPOSE_TASK_SYSTEM
-- 'task_extraction' — tasks lifted from a transcript (EXTRACT_TASKS_SYSTEM)
-- 'cleanup' — transcript cleanup output
--
-- `target_id` is the surface-specific identifier where one exists
-- (subtask id, task id, transcript id). NULL is allowed because
-- not every feedback event has a stable target id yet.
--
-- `context_json` carries the input the AI was conditioned on
-- (parent task text, transcript chunk, etc.) so future prompt
-- builders can reconstruct the original I/O pair for few-shot
-- injection or semantic retrieval.
CREATE TABLE feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_type TEXT NOT NULL
CHECK (target_type IN ('microstep', 'task_extraction', 'cleanup')),
target_id TEXT,
rating INTEGER NOT NULL
CHECK (rating IN (-1, 0, 1)),
original_text TEXT,
corrected_text TEXT,
context_json TEXT,
profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'
REFERENCES profiles(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_feedback_target_type_rating
ON feedback(target_type, rating, created_at DESC);
CREATE INDEX idx_feedback_profile
ON feedback(profile_id, target_type, created_at DESC);
"#,
),
(
11,
"tasks: energy tagging for match-my-energy sort",
r#"
-- Phase 3 of the feature-complete roadmap: replaces the cut
-- temptation-bundling feature with a deterministic client-side
-- sort that matches tasks to the user's current energy state.
-- NULL is the expected normal case — users who never tag get
-- Medium-equivalent treatment at sort time (see Match-my-energy
-- logic in src/lib/pages/TasksPage.svelte).
--
-- profile_id is deliberately absent from the index: tasks
-- currently carry no profile_id column, so a per-profile index
-- is out of scope until the broader task → profile migration
-- lands. See HANDOVER deferred list.
ALTER TABLE tasks
ADD COLUMN energy TEXT
CHECK (energy IS NULL OR energy IN ('high', 'medium', 'brain_dead'));
CREATE INDEX idx_tasks_energy_created
ON tasks(energy, created_at DESC);
"#,
),
];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -483,7 +549,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 9);
assert_eq!(count, 11);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -502,7 +568,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 9);
assert_eq!(count, 11);
}
#[tokio::test]
@@ -859,8 +925,11 @@ mod tests {
// The poisoned migration below first creates `poison_marker`
// (syntactically valid, would succeed against any SQLite) and then
// runs a guaranteed-invalid function call. Under the new atomic
// implementation, neither `poison_marker` nor the v9 row should
// implementation, neither `poison_marker` nor the poison row should
// survive the failed call.
//
// Version number must sit above the real MIGRATIONS max so the
// baseline migrate cleanly finishes first.
#[tokio::test]
async fn multi_statement_migration_rolls_back_on_failure() {
let pool = SqlitePoolOptions::new()
@@ -871,8 +940,18 @@ mod tests {
run_migrations(&pool).await.expect("baseline migrate");
const POISON: &[(i64, &str, &str)] = &[(
10,
// Discover the real max version so the poison migration is
// always exactly one past the end of MIGRATIONS, regardless of
// how many real migrations we add in future.
let real_max: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(&pool)
.await
.expect("read schema_version");
let poison_version = real_max + 1;
let poison: &[(i64, &str, &str)] = &[(
poison_version,
"rb-02 atomicity poison",
r#"
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
@@ -880,7 +959,7 @@ mod tests {
"#,
)];
let result = run_migrations_slice(&pool, POISON).await;
let result = run_migrations_slice(&pool, poison).await;
assert!(
result.is_err(),
"poisoned migration must return Err, got: {result:?}"
@@ -896,14 +975,14 @@ mod tests {
"poison_marker must not exist; got: {marker:?}"
);
// `schema_version` must not include v10 — version insert is part
// of the same transaction that rolled back.
// `schema_version` must not include the poison version — version
// insert is part of the same transaction that rolled back.
let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(&pool)
.await
.expect("read schema_version");
assert_eq!(
max, 9,
max, real_max,
"schema_version must not advance past the failed migration"
);
}

View File

@@ -158,7 +158,7 @@ mod tests {
let mut total_pushed: u64 = 0;
let tentative_per_cycle: u64 = 200;
for _ in 0..100 {
buf.extend(std::iter::repeat(0.25_f32).take(16_000));
buf.extend(std::iter::repeat_n(0.25_f32, 16_000));
total_pushed += 16_000;
let commit_point = total_pushed - tentative_per_cycle;
start = trim_buffer_to_commit_point(&mut buf, start, commit_point);
@@ -199,7 +199,7 @@ mod tests {
// Simulate a capture buffer that has received 1.2 s of audio
// starting at t=0.
let mut buf: Vec<f32> = std::iter::repeat(0.1_f32).take(19_200).collect();
let mut buf: Vec<f32> = std::iter::repeat_n(0.1_f32, 19_200).collect();
let new_start = trim_buffer_to_commit_point(&mut buf, 0, commit_idx);
assert_eq!(new_start, 8_000);
assert_eq!(buf.len(), 19_200 - 8_000);

View File

@@ -306,7 +306,7 @@ impl VadChunker for RmsVadChunker {
.saturating_sub(self.pending.len() as u64);
let pad_len = FRAME_SAMPLES - self.pending.len();
let mut padded = std::mem::take(&mut self.pending);
padded.extend(std::iter::repeat(0.0_f32).take(pad_len));
padded.extend(std::iter::repeat_n(0.0_f32, pad_len));
if let Some(chunk) = self.consume_frame(padded, frame_start) {
emitted.push(chunk);
}

View File

@@ -0,0 +1,328 @@
---
name: Corbie — feature-complete roadmap
description: Build plan from 2026-04-23 baseline to full feature-complete v0.1 release
type: roadmap
tags: [roadmap, planning, corbie, release]
created: 2026/04/23
status: active
author: Wren (CORBEL's resident agent) on behalf of Jake Sames
---
# Corbie — Feature-Complete Roadmap
> **What Corbie is.** A local-first, cognitive-load-aware dictation + task-capture desktop app. Vulkan-accelerated Whisper / Parakeet speech-to-text, a local LLM (Qwen3 tiers) for transcript cleanup and task extraction, an MCP server for integration with Claude Desktop / Cline / Cursor, and a UI designed around ADHD / executive-dysfunction needs. Tauri 2 + Svelte 5 + Rust. Zero telemetry.
>
> **Formerly known as Kon.** Rebrand in flight; repo names at `jakejars/kon` + `git.corbel.consulting/jake/kon` still carry the Kon name and will rename together with the codebase sweep in the final phase.
## Baseline — where we are (2026/04/23)
**Core MVP (from `docs/brief/feature-set.md`):** 9 of 10 complete.
One gap: **visual time representation** (the spec's "#1 community-requested feature" — shrinking colour disks / progress rings, externalising time passage). The rest — local transcription, auto-populating tasks, WIP limits, history + search, light/dark theming, templates, vocabulary profiles, file upload, open-format markdown export — all shipped.
**Post-MVP (designed, not yet prioritised):** 1 of 9 complete.
MicroSteps is shipped; its "just-start" timer button emits an event that currently has no listener anywhere in the codebase. The differentiating ADHD-specific features (Margot nudges, energy-aware sequencing, rituals, if-then intentions, forgiving gamification, TTS, human-in-the-loop feedback) are all documented in the brief but not started.
**Release-blockers:** 1 — RB-08 macOS power-assertion, pending manual verification on a real Mac (Jake's friend Rachmann has a Mac and can run this offline).
**Workspace state:** main is clippy-zero-warnings, 245/245 tests passing, fmt clean, svelte-check clean, npm build clean. Three dependabot bumps landed this session plus a clippy cleanup pass and a needless_range_loop refactor. One orphan design-system WIP branch parked on github as archive.
## Approach
> **Layer 1 first** (per Jake's standing rule): build the features roughly, in series, through Phase 1 Phase 8. Do polish passes in Phase 9 and QC + release in Phase 10. **Do not mix make-it-work and make-it-neat passes** — every phase ships end-to-end (event wired, UI rendered, store state committed, tests updated) but does not chase aesthetic polish until Phase 9.
> **Ordering rationale.** Phase 1 closes the Core MVP gap and unblocks the already-half-wired just-start timer. Phase 2 enables model improvement by collecting human feedback — useful even while later phases are being built. Phases 38 build the differentiating ADHD-specific features from highest-utility-per-effort to lowest. Phase 9 is polish debt. Phase 10 is release prep (including Rachmann's RB-08 verification and the Kon → Corbie codebase rename).
---
## Phase 1 — Visual countdown + Just-Start timer
**Why now.** Closes the one remaining Core MVP gap. Unblocks the dangling `kon:start-timer` emit from MicroSteps. Directly combats time blindness, which the brief names as the single biggest lever for the target audience.
**Scope.**
- `FocusTimer.svelte` — a progress-ring countdown component. Shrinking colour ring (not digital). Subtle colour shift across the last 15%. Remaining time label inside the ring for users who want the number; small enough that the ring dominates the visual field.
- `focusTimer.svelte.ts` store — single active timer, running / paused / completed state, elapsed / remaining computed, event dispatch on state transitions.
- Mount in `+layout.svelte` so the timer persists across page navigation (dictation → tasks → settings).
- Listener for `window` event `kon:start-timer` with `{ durationSeconds, label }` payload.
- Trigger from MicroSteps row (already exists). Trigger from task-row context button (new).
- Floating position top-right of main content, above everything, not intrusive when idle (hidden until a timer is running).
- On completion: gentle chime + 3-second ring flourish + OS notification via `tauri-plugin-notification`. No modal. No guilt copy.
- Store survives window close/reopen via localStorage.
**Out of scope for Phase 1.** Rhythmic voice anchoring (part of Margot, Phase 6). Custom-duration picker (fixed 2 / 5 / 10 / 15 min presets for now). Multi-timer UI.
**Acceptance.** From a MicroStep row, clicking the timer button (a) shows the ring visible somewhere on screen, (b) it counts down, (c) at 0 it fires completion + notification, (d) works across page switches, (e) survives a window close + reopen mid-countdown.
**Estimated effort.** Half day.
---
## Phase 2 — Human-in-the-loop feedback
**Why here.** AI output quality is the single biggest determinant of whether Corbie feels useful. Thumbs-up / thumbs-down on AI-generated micro-steps and task extractions costs almost nothing to add and gives us a feedback corpus the moment anyone uses it. Enables later retraining / prompt tuning.
**Scope.**
- Thumbs-up / thumbs-down buttons on every AI-generated item (micro-step row, extracted task, cleanup paragraph).
- "Edit" path already exists for text; the feedback is additive.
- `feedback` table in SQLite: `item_id`, `item_type`, `rating`, `timestamp`, optional `correction_text`.
- Rust command `record_feedback` + `list_feedback` (for later export).
- No UI surface for viewing feedback yet. Just capture. A future export pass to JSONL feeds prompt-engineering or fine-tuning.
**Out of scope.** Retraining loop, per-user profile adjustment, any UI to view feedback history.
**Acceptance.** Thumbs visible on AI-generated items. Clicking records to SQLite. `cargo test` on storage covers migration + insert + list.
**Estimated effort.** Half day to 1 day.
---
## Phase 3 — Energy-aware task sequencing
**Why here.** Next-highest-utility post-MVP feature. Replaces the cut-for-OS-reasons temptation-bundling feature. Small surface, clear user-facing outcome.
**Scope.**
- `energy` column on tasks: nullable enum `High | Medium | BrainDead`.
- Migration + Rust CRUD.
- Tag chip on task rows; tap to cycle / set.
- Sort / filter option on the tasks page: "Match my energy" → AI surfaces tasks matching a user-set current energy, falls back to `Medium` if unset.
- No automatic energy detection. Pure user input.
**Out of scope.** Time-of-day heuristics, calendar integration, AI-predicted user energy.
**Acceptance.** User tags a task, sets their current energy via a header control, tasks page filter respects it.
**Estimated effort.** 1 day.
---
## Phase 4 — Read Page Aloud (TTS)
**Why here.** Small and self-contained. Engages auditory processing which the brief specifically calls out as a retention lever for the target audience. Uses OS-native TTS (no new dependencies, no model download). Clean single-tap affordance.
**Scope.**
- Rust command `tts_speak(text: String, rate: f32, voice: Option<String>)` — platform dispatch:
- Linux: `spd-say` (speech-dispatcher is available on most distros; graceful fallback to `espeak` if missing).
- macOS: `say` (built in).
- Windows: PowerShell `System.Speech.Synthesis.SpeechSynthesizer`.
- Small "speaker" icon on any text view (transcript viewer, micro-step list, cleanup result).
- Single-tap play; second tap stops. No pause/resume in v1.
- Settings: voice picker (populated from OS), rate slider (0.52.0).
**Out of scope.** Premium voices. Cloud TTS. Concurrent-speaking queue. SSML.
**Acceptance.** Tap speaker icon on a transcript → hear it read on Linux + expected-to-work-on macOS+Windows (test matrix in Phase 10).
**Estimated effort.** Half day.
---
## Phase 5 — Start / shutdown rituals
**Why here.** Meaningful UX but larger surface. Needs calm copy, gentle flow, and a default-off toggle because rituals can feel parental if not optional.
**Scope.**
- Morning triage: on first launch after 06:00, show a modal / dedicated page: "yesterday's incomplete tasks" (from SQLite query: `completed = false AND created_at < today`), with checkbox pick-list, and "pick 13 for today" constraint that refuses selections > 3.
- Evening shutdown: user-triggerable (not scheduled) review: "what got done today", "open loops to close", "separate work from rest" copy. No automation; ritual as reflection.
- Both off by default in settings. When off, no modal, no pressure.
- Skip-for-today button on morning triage; never shows guilt copy.
**Out of scope.** Calendar integration, automatic sleep detection, weekly / monthly reviews.
**Acceptance.** Morning modal shows correct tasks. Selecting > 3 is prevented with a gentle message. Skip works and doesn't re-prompt same day. Evening shutdown opens a reflective page, doesn't block closing the app.
**Estimated effort.** 1 2 days.
---
## Phase 6 — Soft-touch nudging (Margot protocol) — **REVISED 2026/04/23**
**Why here.** Big differentiator. Scheduled late because it nudges *about* the things the earlier phases built (tasks, timers, rituals), and needs careful copy so it doesn't collapse into a push-notification daemon. Spec is explicit: not push notifications — anticipatory guidance.
**Revised architecture — "nudge bus" hybrid.** Earlier drafts proposed a Rust-side OS-activity watcher (keyboard, active window). Cross-platform review flagged this as fragile: Wayland offers no sanctioned global-keyboard API, macOS needs accessibility permission, Windows needs a message-loop hook, and the signal is low quality everywhere. Deferred to post-v0.1.
Phase 6 instead ships a **frontend-owned nudge bus** that consumes signals Corbie already produces:
- Focus-timer state (running / completed / cancelled) — already on `window` events from Phase 1.
- Task-completed events — adds a `kon:task-completed` window event dispatched when `complete_task_cmd` / `complete_subtask_cmd` resolve.
- Micro-step generation — event from the decompose path.
- Ritual state — `ritualsMorning`, `lastTriageDate` from settings/storage.
- App focus / visibility — `document.visibilitychange` + Tauri window `focus`/`blur` events.
The bus applies suppression rules, then dispatches via Rust commands for platform-native delivery.
**Notification plugin prerequisites (new cross-cutting work, done in Phase 6).**
- Add `tauri-plugin-notification = "2"` (Cargo.toml) + `@tauri-apps/plugin-notification` (package.json).
- Register the plugin in `lib.rs`.
- Frontend checks `isPermissionGranted()` and calls `requestPermission()` on first use.
- Expose ACL entries in `src-tauri/capabilities/default.json`.
- Windows caveat: notifications only deliver properly for *installed* apps (not `tauri dev` builds). Flag this in release notes and the dev HANDOVER.
- Sound path: Windows expects a `.wav` file path, not a platform sound name. Drop the original spec's `"Default"` string for Windows; ship a tiny custom `.wav` in `src-tauri/sounds/`. macOS `"Glass"` is valid. Linux freedesktop sound-name `message-new-instant` works via `tauri-plugin-notification`'s `sound` option.
**Scope (revised).**
- `nudgeBus.svelte.ts` store — subscribes to the in-app signals above, owns cooldown/suppression logic.
- Rust command `deliver_nudge(title, body, sound?)` — thin wrapper around `tauri-plugin-notification` that also persists a row to a new `nudges` SQLite table (for debugging + future analytics).
- **Trigger set for v1 (all in-app, no OS-wide detection):**
- `inactivity_with_active_timer` — timer running + `document.visibilitystate === 'hidden'` OR window `blur` for 90 s continuous. (We know the user has switched away; we don't need to know what they switched to.)
- `pending_morning_triage` — past 10:00 local + triage enabled + last-shown ≠ today. Fires once per day; gets suppressed forever if user later skips or completes.
- `micro_step_idle` — micro-step created + no `kon:task-completed` or `kon:step-completed` event for that parent-task-id within 15 min.
- **Suppression rules:**
- Global mute in settings (on/off).
- Hard cap 3 nudges per rolling hour.
- No nudge in first 60 s of a timer.
- No nudge during app focus (the user is already looking).
- Rhythmic voice anchoring: piggy-back on Phase 4 TTS. Optional "speak nudges aloud" toggle. Default off. British-English calm lines: "Time to move on", "Your list is still here". No personality yet — Phase 9.
**Out of scope.**
- OS-wide activity detection (keyboard hooks, active-window polling). Deferred post-v0.1 as a separate phase, if a real need emerges.
- Custom trigger editor (owned by Phase 7).
- Biometric signals. Any Margot-as-character visual.
**Acceptance.** Each trigger fires on its defined condition in a dogfood walkthrough. Suppression observed (hidden-for-90 s → nudge; return-to-focus → no nudge). Global mute kills everything immediately. Notification permission request appears on first trigger; denial is respected.
**Estimated effort.** 1 2 days (including notification-plugin setup and the nudges table).
---
## Phase 7 — Implementation intentions (if-then automation) — **REVISED 2026/04/23**
**Why here.** Leans on Phase 6's nudge bus. User-defined rules reuse the same delivery path.
**Rule idempotency (new explicit requirement).**
- Each rule stores `last_fired_at: ISO8601` and, for daily rules, `last_fired_local_date: YYYY-MM-DD`. Without this, a poll-driven "at 09:00" fires on every tick.
- On sleep/resume: on app focus after > 10 minutes away, check each time-of-day rule; if today's fire time has passed and `last_fired_local_date` is not today, fire once and update. Configurable per-rule toggle: `catch_up_on_resume` (default ON for time-of-day rules).
- "After a task completes" rule: subscribes to the `kon:task-completed` event from Phase 6. Rule fires once per task id (guarded via `last_fired_task_ids`).
- "Morning triage finishes" rule: fires on *either* "Start the day" or "Skip for today" — skip counts as finishing. Fires once per `last_fired_local_date`.
**Scope.**
- Rule editor UI. Minimal: `if [when-condition], then [action]`.
- When-conditions for v1: `time of day = HH:MM`, `after a task completes` (pick a specific task from list), `morning triage finishes`.
- Actions for v1: `surface a specific task` (jump to Tasks page + highlight), `start a 5-min timer`, `speak a line aloud` (reuses Phase 4 TTS).
- Rules stored in SQLite (`rules` table: id, name, when_json, then_json, enabled, last_fired_at, last_fired_local_date, last_fired_task_ids). Global mute respected.
- No location triggers (desktop app, no geolocation). No app-running detection (fragile cross-platform — revisit post-v0.1).
**Out of scope.** Calendar triggers, cross-app automation, macro-style action chains, shared/community rules.
**Acceptance.** User can write "at 09:00, speak 'time to plan the day' aloud and surface my 'daily standup' task", save it, have it fire next morning at 09:00. Polling tick does not re-fire it. Delete works. Sleep the machine through 09:00; on resume the rule catches up once, then goes quiet until tomorrow.
**Estimated effort.** 1 1.5 days.
---
## Phase 8 — Forgiving gamification — **REVISED 2026/04/23**
**Why here.** Low-risk, closes the spec list.
**Scope (revised — grace days dropped).**
- Completion count per day (non-punitive). "You've finished 3 today."
- **No streaks, no chains** — so the original "grace days" logic was solving a problem that doesn't exist in this design. Dropped.
- Optional "recent momentum" sparkline: last 7 days' daily completion counts as a tiny inline chart on the Tasks header. Always additive; empty days render as baseline, never as gaps.
- Visual: soft-edged numeric badges on the Tasks header. No leaderboards, no social comparison.
- Zero loss language. Always "look what you did".
**Out of scope.** Leaderboards. Shared challenges. Streak repair purchases. XP systems.
**Acceptance.** Complete 3 tasks today → header shows "3 today". Open the app after 4 days off → no "you were away" framing; header reads today's count only; sparkline simply shows flat zero bars for the away days.
**Estimated effort.** Half day.
---
## Phase 9 — Polish debt
> **All Phase 9 work is paused until Phase 1 Phase 8 are closed.** Per Jake's rule: features first, polish second.
**Contents.**
- File-system `.md` save dialog (replace clipboard-only export). Rust `write_text_file` command; platform dialog via `tauri-plugin-dialog`.
- Bulk select + bulk export in History.
- LLM-powered content tags (`topic:*`, `intent:*`). Slot into the existing `kon-llm` stub.
- Settings UX overhaul: bundle high-traffic settings into a "Start here" group; hide advanced behind a disclosure.
- Visual polish pass on all Phase 1 Phase 8 surfaces: spacing, typography, motion curves, colour, dark-mode parity.
- Accessibility pass: keyboard navigation, screen reader labels, focus order, colour contrast audit against WCAG AA.
**Estimated effort.** 1 2 days.
---
## Phase 10 — QC + rename + release — **SPLIT 2026/04/23**
Earlier draft rolled QC, a full codebase rename, an app-data migration shim, and the release ceremony into one day. Review flagged this as unrealistic and split it. The rename sweep in particular crosses every surface in the app; rushing it is how you end up with `kon.db` on half the users' machines and `corbie.db` on the rest.
### Pre-Phase-10: Cargo.lock policy decision
- `.gitignore` currently excludes `Cargo.lock`. For a Tauri binary workspace this is the wrong default — CI resolves dependencies fresh each run, which is the leading theory for the 2026-04-24 CI red-state noted in `HANDOVER.md`.
- **Decision needed before tagging v0.1.0:** commit `Cargo.lock`. Remove the `.gitignore` line in a dedicated commit, run `cargo generate-lockfile` if needed, commit the lockfile, watch CI for one green cycle.
- Captures the dep set users actually get from the release artefacts rather than whatever crates.io happened to resolve at build time.
### Phase 10a — QC (estimated half day)
**Prerequisite:** Phase 1 Phase 9 complete. Cargo.lock committed.
- Full dogfood walkthrough: record a real brain-dump → clean transcript → task extraction → micro-step one task → run a focus timer → tag energy → complete → open evening wind-down → skip morning triage → re-check no re-prompt.
- RB-08 macOS power-assertion verification: **Rachmann runs this offline** on his Mac. `pmset -g assertions` during a live session; expected `PreventSystemSleep` attributed to Corbie's bundle id. On confirmation, close RB-08 and move `docs/issues/power-assertion-macos-objc2.md` to `docs/issues/resolved/`.
- Cross-platform build matrix green across Linux / macOS / Windows.
- Accessibility regression check: keyboard-only traversal of every new Phase 5 Phase 8 surface.
- Freshly-clean install test on a spare user account: no stray data leaks from dev.
Rachmann's Mac slot runs in parallel; not blocking the rest of 10a.
### Phase 10b — Kon → Corbie rename sweep (estimated half day to 1 day)
Runs **after** Phase 10a QC, **after** Jake has renamed the two repos in GitHub + Gitea web UIs. The rename only starts here, not earlier, so in-flight Phase 1 Phase 9 work doesn't have to re-learn event names mid-cycle.
- `package.json``name: "corbie"`, `description` update. (Version stays at `0.1.0`.)
- Cargo crates: `kon`, `kon-audio`, `kon-storage`, `kon-transcription`, `kon-llm`, `kon-ai-formatting`, `kon-core`, `kon-cloud-providers`, `kon-hotkey`, `kon-mcp``corbie-*`. Mass-rename via `Cargo.toml` name field + workspace path references + `use` imports.
- Binary + product names: `src-tauri/tauri.conf.json` (productName, identifier), `.desktop` file, Windows product name, macOS bundle name.
- Install paths: `~/.local/share/kon/``~/.local/share/corbie/` (plus the macOS `~/Library/Application Support` and Windows `%APPDATA%` equivalents). **App-data migration shim required** — first-run checks for the old dir, moves contents, writes a sentinel `.migrated-from-kon`. Shim must handle the case where both dirs exist (prefer new, log the duplicate).
- Database filename: `kon.db``corbie.db`. Handled by the same shim.
- Window titles, tray tooltip, About-dialog, README body, docs references where they name the product (leave historical brief content talking about "Kon" — it's a historical document).
- Event names: `kon:start-timer`, `kon:task-completed`, `kon:open-wind-down`, `kon:preferences-changed`, `kon:hotkey-pressed`, `kon:llm-download-progress``corbie:*`. Single commit; one find-replace; both emitter and listener in the same diff.
- Logs, error messages, user-facing copy (including toast strings that mention "Kon").
- Settings SQLite key: `kon_preferences``corbie_preferences`. Migration reads old key on first launch, writes new key, deletes old.
- Remotes: `ssh://git.corbel.consulting:2222/jake/kon.git` + `github.com:jakejars/kon.git``…/corbie.git`. `git remote set-url` locally after web-UI renames.
### Phase 10c — Release (estimated half day)
- Version is already `0.1.0` in `Cargo.toml`, `package.json`, and `tauri.conf.json` — no bump needed. Confirm the three match.
- Write `CHANGELOG.md`. Seed from this roadmap's phases. Entries are written to end-users, not engineers — "You can now read transcripts aloud" not "Added tts_speak command".
- Write release notes in plain language: what it does, who it's for, the Kon-data migration note, the Windows notifications caveat (installed app only).
- Tag `v0.1.0` on the head commit.
- Push tag to both remotes. GitHub Actions release workflow auto-builds artefacts for Linux / macOS / Windows.
- Smoke-test at least one artefact per platform (ideally Rachmann covers macOS) before the release is made public.
**Estimated effort (Phase 10 total).** 1 2 days across 10a / 10b / 10c plus Rachmann's parallel Mac session.
---
## Totals
- Phase 1 8 feature build: **6 9 days** of focused work
- Phase 9 polish: **1 2 days**
- Phase 10 QC + rename + release (split): **1 2 days** + Rachmann's Mac session
**Total to v0.1.0 feature-complete release:** **~8 13 days of focused work**, depending on how much polish time Jake wants in Phase 9. Revised downward at the lower end after the Phase 8 grace-day drop and Phase 10 split clarified actual scope.
## Explicit non-goals
- Mobile apps. Corbie is desktop-first; a mobile companion is post-v0.1.
- Cloud sync. Local-first is the floor, not a feature. Sync is out of scope through v0.1.
- Premium voices, paid tiers, subscription. Licensing + monetisation is a separate track tracked in memory `project_marketplace_creem`.
- AI body doubling (low-fi focus rooms) — validated but parked to post-v0.1.
- Temptation bundling — cut (OS-integration impossible cross-platform; replaced by Phase 3 energy-aware sequencing).
## Post-v0.1 ideas (captured, not scheduled)
Ideas worth keeping warm once the v0.1 release is out. Not tracked as phases until the core release is done.
- **Calendar integration.** Read-only first pass could parse a local ICS file (Thunderbird / Evolution export) and surface day events alongside the tasks list — stays local-first. A cloud-sync pass (Google / iCloud / CalDAV) is a v0.2+ conversation because it re-opens credential handling and refresh-token plumbing that v0.1 deliberately avoids.
- **Right-click highlighted text → capture as task.** Two flavours: (a) *In-Corbie* — context menu on a selected transcript range or viewer segment, routes to `create_task_cmd` with the selection as text. Small — lives naturally in Phase 9 polish or a bolt-on. (b) *System-wide* — highlight anywhere (browser, Slack, IDE) and call Corbie. Platform-painful: macOS Services API, Windows shell-extension, Linux desktop-env-specific context menus. Scope as a separate post-v0.1 phase.
## Anchors
- Spec: [docs/brief/feature-set.md](docs/brief/feature-set.md) + [docs/brief/design-principles.md](docs/brief/design-principles.md)
- Current baseline: this session's HANDOVER.md
- Rebrand memory: `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_corbie_rebrand.md`
- Release-blocker index: [docs/issues/README.md](docs/issues/README.md)
---
*This roadmap is a living document. Update it at the end of each phase with actuals vs estimates and any scope revisions.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

100
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": {
"@chenglou/pretext": "0.0.5",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2",
@@ -18,8 +19,8 @@
"svelte-i18n": "^4.0.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.58.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
@@ -27,7 +28,7 @@
"svelte-check": "^4.0.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.6.2",
"vite": "^6.0.3"
"vite": "^6.4.2"
}
},
"node_modules/@chenglou/pretext": {
@@ -958,9 +959,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.55.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"version": "2.58.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz",
"integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -987,7 +988,7 @@
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3",
"typescript": "^5.3.3 || ^6.0.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
@@ -1262,6 +1263,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
@@ -1538,6 +1603,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-autostart": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz",
"integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
@@ -2375,9 +2449,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3098,9 +3172,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -16,6 +16,7 @@
"dependencies": {
"@chenglou/pretext": "0.0.5",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2",
@@ -23,8 +24,8 @@
"svelte-i18n": "^4.0.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.58.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
@@ -32,6 +33,6 @@
"svelte-check": "^4.0.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.6.2",
"vite": "^6.0.3"
"vite": "^6.4.2"
}
}

View File

@@ -44,6 +44,10 @@ tauri-plugin-dialog = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
# Phase 5 rituals: register Corbie as a login-time autostart entry.
# Handles platform differences (.desktop file on Linux, LaunchAgents plist
# on macOS, registry Run key on Windows) behind a single API.
tauri-plugin-autostart = "2"
# Serialisation
serde = { version = "1", features = ["derive"] }
@@ -78,3 +82,8 @@ gdk = "0.18"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.4"
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString", "NSProcessInfo"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Phase 4 TTS: PowerShell -EncodedCommand expects UTF-16-LE base64.
# Windows-only because the other platforms' TTS paths pass text via argv.
base64 = "0.22"

View File

@@ -64,7 +64,7 @@ fn assert_localhost_llm_csp() {
let tokens: Vec<&str> = connect_src.split_whitespace().collect();
for required in ["http://127.0.0.1:*", "ws://127.0.0.1:*"] {
assert!(
tokens.iter().any(|t| *t == required),
tokens.contains(&required),
"build.rs: tauri.conf.json CSP connect-src must permit {required} \
for local LLM connectivity (brief item #2). Current connect-src: \
{connect_src:?}"

View File

@@ -17,6 +17,9 @@
"opener:default",
"dialog:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister"
"global-shortcut:allow-unregister",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister"]}}
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled"]}}

View File

@@ -344,6 +344,48 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
"type": "string",
"const": "autostart:default",
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
},
{
"description": "Enables the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-disable",
"markdownDescription": "Enables the disable command without any pre-configured scope."
},
{
"description": "Enables the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-enable",
"markdownDescription": "Enables the enable command without any pre-configured scope."
},
{
"description": "Enables the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-is-enabled",
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
},
{
"description": "Denies the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-disable",
"markdownDescription": "Denies the disable command without any pre-configured scope."
},
{
"description": "Denies the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-enable",
"markdownDescription": "Denies the enable command without any pre-configured scope."
},
{
"description": "Denies the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-is-enabled",
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",

View File

@@ -344,6 +344,48 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
"type": "string",
"const": "autostart:default",
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
},
{
"description": "Enables the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-disable",
"markdownDescription": "Enables the disable command without any pre-configured scope."
},
{
"description": "Enables the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-enable",
"markdownDescription": "Enables the enable command without any pre-configured scope."
},
{
"description": "Enables the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-is-enabled",
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
},
{
"description": "Denies the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-disable",
"markdownDescription": "Denies the disable command without any pre-configured scope."
},
{
"description": "Denies the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-enable",
"markdownDescription": "Denies the enable command without any pre-configured scope."
},
{
"description": "Denies the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-is-enabled",
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",

View File

@@ -247,7 +247,7 @@ pub async fn generate_diagnostic_report(
.map(|d| d.as_secs())
.unwrap_or(0);
out.push_str(&format!("- Generated: unix `{}`\n", now));
out.push_str("\n");
out.push('\n');
out.push_str(
"> This report is local-only until you choose to share it. \
Review the contents below before sending to anyone.\n\n",

View File

@@ -0,0 +1,110 @@
// Tauri commands for human-in-the-loop feedback capture and retrieval.
// Phase 2 of the feature-complete roadmap: thumbs + correction capture
// on AI-generated output feeds a few-shot loop that conditions future
// prompts on the user's preferred style.
use serde::{Deserialize, Serialize};
use kon_storage::{
list_feedback_examples as db_list_feedback_examples, record_feedback as db_record_feedback,
FeedbackRow, FeedbackTargetType, RecordFeedbackParams,
};
use crate::AppState;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordFeedbackInput {
/// One of "microstep", "task_extraction", "cleanup".
pub target_type: String,
/// Optional surface-specific id (subtask id, task id, transcript id).
#[serde(default)]
pub target_id: Option<String>,
/// -1 = thumbs down, 0 = correction (neutral), +1 = thumbs up.
pub rating: i8,
#[serde(default)]
pub original_text: Option<String>,
#[serde(default)]
pub corrected_text: Option<String>,
/// Freeform JSON context: e.g. the parent task text, the transcript
/// chunk the AI was given, etc. Used later by the prompt builder
/// to reconstruct the (input, preferred-output) pair.
#[serde(default)]
pub context_json: Option<String>,
#[serde(default)]
pub profile_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FeedbackDto {
pub id: i64,
pub target_type: String,
pub target_id: Option<String>,
pub rating: i64,
pub original_text: Option<String>,
pub corrected_text: Option<String>,
pub context_json: Option<String>,
pub profile_id: String,
pub created_at: String,
}
impl From<FeedbackRow> for FeedbackDto {
fn from(r: FeedbackRow) -> Self {
Self {
id: r.id,
target_type: r.target_type,
target_id: r.target_id,
rating: r.rating,
original_text: r.original_text,
corrected_text: r.corrected_text,
context_json: r.context_json,
profile_id: r.profile_id,
created_at: r.created_at,
}
}
}
fn parse_target_type(raw: &str) -> Result<FeedbackTargetType, String> {
FeedbackTargetType::parse(raw).ok_or_else(|| format!("unknown feedback target_type: {raw}"))
}
#[tauri::command]
pub async fn record_feedback(
state: tauri::State<'_, AppState>,
input: RecordFeedbackInput,
) -> Result<i64, String> {
let target_type = parse_target_type(&input.target_type)?;
db_record_feedback(
&state.db,
RecordFeedbackParams {
target_type,
target_id: input.target_id,
rating: input.rating,
original_text: input.original_text,
corrected_text: input.corrected_text,
context_json: input.context_json,
profile_id: input.profile_id,
},
)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_feedback_examples_cmd(
state: tauri::State<'_, AppState>,
target_type: String,
limit: Option<i64>,
min_rating: Option<i8>,
profile_id: Option<String>,
) -> Result<Vec<FeedbackDto>, String> {
let target = parse_target_type(&target_type)?;
let limit = limit.unwrap_or(8).clamp(1, 64);
let min_rating = min_rating.unwrap_or(0).clamp(-1, 1);
let rows =
db_list_feedback_examples(&state.db, target, limit, min_rating, profile_id.as_deref())
.await
.map_err(|e| e.to_string())?;
Ok(rows.into_iter().map(FeedbackDto::from).collect())
}

View File

@@ -1086,11 +1086,12 @@ fn build_nearby_transcript_candidates(
let mut texts: Vec<String> = Vec::new();
for start in 0..nearby.len() {
let mut merged = String::new();
for end in start..nearby.len().min(start + DUPLICATE_TRANSCRIPT_MERGE_LIMIT) {
let upper = nearby.len().min(start + DUPLICATE_TRANSCRIPT_MERGE_LIMIT);
for segment in &nearby[start..upper] {
if !merged.is_empty() {
merged.push(' ');
}
merged.push_str(nearby[end].text.trim());
merged.push_str(segment.text.trim());
if !texts.iter().any(|existing| existing == &merged) {
texts.push(merged.clone());
}
@@ -1155,12 +1156,10 @@ fn longest_common_token_subsequence(a: &[&str], b: &[&str]) -> usize {
}
fn is_low_signal_token(token: &str) -> bool {
LOW_SIGNAL_TOKENS
.iter()
.any(|low_signal| *low_signal == token)
LOW_SIGNAL_TOKENS.contains(&token)
}
fn meaningful_tokens<'a>(text: &'a str) -> Vec<&'a str> {
fn meaningful_tokens(text: &str) -> Vec<&str> {
text.split_whitespace()
.filter(|token| !token.is_empty() && token.len() > 1 && !is_low_signal_token(token))
.collect()

View File

@@ -1,6 +1,7 @@
pub mod audio;
pub mod clipboard;
pub mod diagnostics;
pub mod feedback;
pub mod hardware;
pub mod hotkey;
pub mod live;
@@ -10,9 +11,11 @@ pub mod models;
pub mod paste;
pub mod power;
pub mod profiles;
pub mod rituals;
pub mod tasks;
pub mod transcription;
pub mod transcripts;
pub mod tts;
pub mod update;
pub mod windows;

View File

@@ -412,7 +412,7 @@ pub fn detect_active_compute_device() -> ActiveComputeDevice {
reason: None,
};
}
return ActiveComputeDevice {
ActiveComputeDevice {
kind: "cpu".into(),
label: "CPU (fallback)".into(),
reason: Some(
@@ -420,7 +420,7 @@ pub fn detect_active_compute_device() -> ActiveComputeDevice {
libvulkan1 (Linux) to enable GPU acceleration."
.into(),
),
};
}
}
}

View File

@@ -345,7 +345,7 @@ fn classify_terminal(raw: &str) -> Option<String> {
fn detect_focused_window_class() -> Option<String> {
#[cfg(target_os = "linux")]
{
return detect_focused_window_class_linux();
detect_focused_window_class_linux()
}
#[cfg(target_os = "macos")]
{

View File

@@ -0,0 +1,43 @@
//! Phase 5 of the feature-complete roadmap: start- and shutdown-rituals.
//!
//! Layer-1 scope is narrow. The frontend owns rendering and logic; this
//! module only persists the "last date the morning triage modal was
//! shown" sentinel so the modal can refuse to re-prompt on the same
//! calendar day. Task queries re-use `list_tasks_cmd` with client-side
//! filtering rather than adding a second query path.
//!
//! Stored under the existing SQLite settings table via
//! `kon_storage::{get_setting, set_setting}` — same bag as
//! `kon_preferences`.
use kon_storage::{get_setting, set_setting};
use crate::AppState;
const LAST_TRIAGE_KEY: &str = "kon_morning_triage_last_shown";
/// Returns the YYYY-MM-DD date string stored on the last successful
/// morning triage dismissal, or `None` if the user has never been
/// shown the modal.
#[tauri::command]
pub async fn get_last_morning_triage(
state: tauri::State<'_, AppState>,
) -> Result<Option<String>, String> {
get_setting(&state.db, LAST_TRIAGE_KEY)
.await
.map_err(|e| e.to_string())
}
/// Records that the morning triage modal was shown (and either skipped
/// or completed) on `date`. Caller is responsible for passing a valid
/// YYYY-MM-DD string in the user's local timezone — Rust deliberately
/// stays timezone-agnostic here so the frontend retains control.
#[tauri::command]
pub async fn mark_morning_triage_shown(
state: tauri::State<'_, AppState>,
date: String,
) -> Result<(), String> {
set_setting(&state.db, LAST_TRIAGE_KEY, &date)
.await
.map_err(|e| e.to_string())
}

View File

@@ -6,12 +6,15 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use kon_llm::prompts::FeedbackExample as LlmFeedbackExample;
use kon_storage::{
complete_subtask_and_check_parent as db_complete_subtask, complete_task as db_complete_task,
delete_task as db_delete_task, get_task_by_id as db_get_task,
insert_subtask as db_insert_subtask, insert_task as db_insert_task,
list_subtasks as db_list_subtasks, list_tasks as db_list_tasks,
uncomplete_task as db_uncomplete_task, update_task as db_update_task, TaskRow,
list_feedback_examples as db_list_feedback_examples, list_subtasks as db_list_subtasks,
list_tasks as db_list_tasks, set_task_energy as db_set_task_energy,
uncomplete_task as db_uncomplete_task, update_task as db_update_task, FeedbackRow,
FeedbackTargetType, TaskRow,
};
use crate::AppState;
@@ -31,6 +34,7 @@ pub struct TaskDto {
pub created_at: String,
pub source_transcript_id: Option<String>,
pub parent_task_id: Option<String>,
pub energy: Option<String>,
}
impl From<TaskRow> for TaskDto {
@@ -47,10 +51,27 @@ impl From<TaskRow> for TaskDto {
created_at: r.created_at,
source_transcript_id: r.source_transcript_id,
parent_task_id: r.parent_task_id,
energy: r.energy,
}
}
}
/// Accepted energy tag values. Kept as a const so frontend and storage
/// validate against the same list. Migration v11 enforces the same
/// set via a CHECK constraint.
const ENERGY_LEVELS: &[&str] = &["high", "medium", "brain_dead"];
fn validate_energy(raw: Option<&str>) -> Result<Option<&str>, String> {
match raw {
None => Ok(None),
Some(s) if ENERGY_LEVELS.contains(&s) => Ok(Some(s)),
Some(other) => Err(format!(
"energy must be one of {:?} or null, got {:?}",
ENERGY_LEVELS, other
)),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTaskRequest {
@@ -63,6 +84,8 @@ pub struct CreateTaskRequest {
pub list_id: Option<String>,
#[serde(default)]
pub effort: Option<String>,
#[serde(default)]
pub energy: Option<String>,
}
#[tauri::command]
@@ -70,6 +93,7 @@ pub async fn create_task_cmd(
state: tauri::State<'_, AppState>,
request: CreateTaskRequest,
) -> Result<TaskDto, String> {
let energy = validate_energy(request.energy.as_deref())?;
db_insert_task(
&state.db,
&request.id,
@@ -78,6 +102,7 @@ pub async fn create_task_cmd(
request.source_transcript_id.as_deref(),
request.list_id.as_deref(),
request.effort.as_deref(),
energy,
)
.await
.map_err(|e| e.to_string())?;
@@ -166,22 +191,138 @@ pub async fn uncomplete_task_cmd(
.map_err(|e| e.to_string())
}
/// Phase 3: set or clear the `energy` tag on a task. Dedicated command
/// rather than a field on `update_task_cmd` because the existing update
/// path uses `COALESCE` semantics where `None` means "preserve" — which
/// makes clearing the tag impossible. This command always writes exactly
/// what you send, including `None` to explicitly clear.
#[tauri::command]
pub async fn set_task_energy_cmd(
state: tauri::State<'_, AppState>,
id: String,
energy: Option<String>,
) -> Result<TaskDto, String> {
let validated = validate_energy(energy.as_deref())?;
let row = db_set_task_energy(&state.db, &id, validated)
.await
.map_err(|e| e.to_string())?;
Ok(TaskDto::from(row))
}
/// Convert HITL feedback rows fetched from storage into the few-shot
/// exemplar shape the LLM crate consumes. We reconstruct the `input`
/// (parent task text, transcript chunk) from `context_json` where the
/// recorder has stored it. Rows without usable input are dropped —
/// the prompt builder filters them too, but doing it here keeps the
/// exemplar list tight and the prompt budget predictable.
///
/// Malformed `context_json` is logged rather than silently dropped so
/// data-integrity regressions surface instead of disappearing.
fn to_llm_examples(rows: Vec<FeedbackRow>) -> Vec<LlmFeedbackExample> {
rows.into_iter()
.filter_map(|r| {
let raw = r.context_json.as_deref().unwrap_or("{}");
let ctx: serde_json::Value = match serde_json::from_str(raw) {
Ok(v) => v,
Err(e) => {
eprintln!(
"[feedback] skipping row id={} with malformed context_json: {e}",
r.id
);
return None;
}
};
let input = ctx
.get("input")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_default();
if input.trim().is_empty() {
return None;
}
Some(LlmFeedbackExample {
input,
original_output: r.original_text,
corrected_output: r.corrected_text,
})
})
.collect()
}
/// Rough character budget for the few-shot block. Qwen3's tokenizer
/// averages ~3.5 chars per token in English, so 2000 chars is ~570
/// tokens — well inside the 64-token reserve + response-token gap
/// against the 8192-token context cap (see `LlmEngine::generate`).
///
/// Exceed this and we drop the oldest examples first. Rationale: the
/// retrieval already orders most-recent-first, and the most recent
/// correction is usually the one carrying the user's live preference.
const FEW_SHOT_CHAR_BUDGET: usize = 2000;
fn example_char_cost(ex: &LlmFeedbackExample) -> usize {
// Matches the render path in `prompts::render_feedback_exemplar`:
// "Input: {input}\nGood output: {good}". Prefix strings + newlines
// + the two bodies. Slight overestimate to leave headroom.
let good_len = ex
.corrected_output
.as_deref()
.or(ex.original_output.as_deref())
.map(str::len)
.unwrap_or(0);
ex.input.len() + good_len + 24
}
fn trim_to_budget(mut examples: Vec<LlmFeedbackExample>) -> Vec<LlmFeedbackExample> {
let mut running = 0usize;
let mut kept = Vec::with_capacity(examples.len());
for ex in examples.drain(..) {
let cost = example_char_cost(&ex);
if running + cost > FEW_SHOT_CHAR_BUDGET {
break;
}
running += cost;
kept.push(ex);
}
kept
}
#[tauri::command]
pub async fn decompose_and_store(
state: tauri::State<'_, AppState>,
parent_task_id: String,
profile_id: Option<String>,
) -> Result<Vec<TaskDto>, String> {
let parent = db_get_task(&state.db, &parent_task_id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Task {parent_task_id} not found"))?;
// Pull recent micro-step feedback so the system prompt gets
// conditioned on the user's preferred decomposition style. We
// cap at 5 examples AND at a char budget to keep the prompt
// under token budget regardless of how much feedback has been
// captured, and scope by profile so per-profile styles do not
// leak into each other.
let examples = db_list_feedback_examples(
&state.db,
FeedbackTargetType::MicroStep,
5,
0,
profile_id.as_deref(),
)
.await
.map(to_llm_examples)
.map(trim_to_budget)
.unwrap_or_default();
let engine = state.llm_engine.clone();
let parent_text = parent.text.clone();
let steps = tokio::task::spawn_blocking(move || engine.decompose_task(&parent_text))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let steps = tokio::task::spawn_blocking(move || {
engine.decompose_task_with_feedback(&parent_text, &examples)
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let mut created = Vec::new();
for text in steps {
@@ -204,9 +345,22 @@ pub async fn decompose_and_store(
pub async fn extract_tasks_from_transcript_cmd(
state: tauri::State<'_, AppState>,
transcript: String,
profile_id: Option<String>,
) -> Result<Vec<String>, String> {
let examples = db_list_feedback_examples(
&state.db,
FeedbackTargetType::TaskExtraction,
5,
0,
profile_id.as_deref(),
)
.await
.map(to_llm_examples)
.map(trim_to_budget)
.unwrap_or_default();
let engine = state.llm_engine.clone();
tokio::task::spawn_blocking(move || engine.extract_tasks(&transcript))
tokio::task::spawn_blocking(move || engine.extract_tasks_with_feedback(&transcript, &examples))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())

View File

@@ -0,0 +1,421 @@
//! Phase 4 of the feature-complete roadmap: platform-native Read Page Aloud.
//!
//! Layer-1 scope: shell out to the OS's built-in TTS binary, return
//! immediately (non-blocking), track the spawned child so `tts_stop`
//! can cancel in-flight speech. No SSML, no pause/resume, no cloud
//! voices. User text is never interpolated into a shell string —
//! every platform passes the text via argv (or, on Windows, inside a
//! PowerShell here-string delivered through `-EncodedCommand`).
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use serde::Serialize;
/// Active synth child process, if any. On Linux `spd-say` returns
/// immediately so the slot is usually empty; macOS `say` and Windows
/// PowerShell speak synchronously, so we store the handle to kill
/// on `tts_stop`.
#[derive(Default)]
pub struct TtsState {
child: Mutex<Option<Child>>,
}
impl TtsState {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TtsVoice {
pub id: String,
pub name: String,
pub language: Option<String>,
}
/// Clamp user-supplied rate into the app's supported range.
/// `0.5` = half speed, `1.0` = normal, `2.0` = double speed.
pub fn clamp_rate(rate: f32) -> f32 {
if !rate.is_finite() {
return 1.0;
}
rate.clamp(0.5, 2.0)
}
// ---------- Linux (spd-say, espeak-ng fallback) ----------
#[cfg(target_os = "linux")]
pub fn spd_rate(rate: f32) -> i32 {
// spd-say -r takes -100..=100. 1.0 -> 0 (default rate). We map the
// 1.0..=2.0 half linearly to 0..=100, and the 0.5..=1.0 half
// linearly to -50..=0. Asymmetric but simple; users who want
// slower playback than -50 can change the system synth rate in
// their accessibility settings.
let r = clamp_rate(rate);
((r - 1.0) * 100.0).round().clamp(-100.0, 100.0) as i32
}
#[cfg(target_os = "linux")]
pub fn espeak_rate(rate: f32) -> u32 {
// espeak-ng -s: words per minute (default 175, min 80, max 450).
let r = clamp_rate(rate);
((r * 175.0).round() as i32).clamp(80, 450) as u32
}
#[cfg(target_os = "linux")]
fn spawn_linux(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
let mut cmd = Command::new("spd-say");
cmd.arg("-r").arg(spd_rate(rate).to_string());
if let Some(v) = voice {
cmd.arg("-t").arg(v);
}
cmd.arg("--").arg(text);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match cmd.spawn() {
// spd-say is non-blocking — the child exits before speech
// finishes, so there's nothing useful to track.
Ok(_) => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let mut fb = Command::new("espeak-ng");
fb.arg("-s").arg(espeak_rate(rate).to_string());
if let Some(v) = voice {
fb.arg("-v").arg(v);
}
fb.arg("--").arg(text);
fb.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
fb.spawn()
.map(Some)
.map_err(|e| format!("neither spd-say nor espeak-ng is available: {e}"))
}
Err(e) => Err(format!("failed to spawn spd-say: {e}")),
}
}
#[cfg(target_os = "linux")]
fn stop_linux() {
// Cancels all active spd-say messages for this user. Silent
// failure is fine — if spd-say isn't installed there's nothing
// to cancel, and any tracked espeak-ng child is killed separately.
let _ = Command::new("spd-say")
.arg("-S")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
// ---------- macOS (say) ----------
#[cfg(target_os = "macos")]
pub fn say_rate(rate: f32) -> u32 {
// `say -r`: words per minute. 180 wpm is roughly the default; clamp
// to a sane range so the slider can't produce an unreadable rate.
let r = clamp_rate(rate);
((r * 180.0).round() as i32).clamp(90, 500) as u32
}
#[cfg(target_os = "macos")]
fn spawn_macos(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
let mut cmd = Command::new("say");
cmd.arg("-r").arg(say_rate(rate).to_string());
if let Some(v) = voice {
cmd.arg("-v").arg(v);
}
cmd.arg("--").arg(text);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn()
.map(Some)
.map_err(|e| format!("failed to spawn `say`: {e}"))
}
// ---------- Windows (PowerShell System.Speech) ----------
#[cfg(target_os = "windows")]
pub fn win_rate(rate: f32) -> i32 {
// SpeechSynthesizer.Rate is an integer in -10..=10.
let r = clamp_rate(rate);
((r - 1.0) * 10.0).round().clamp(-10.0, 10.0) as i32
}
/// A PowerShell single-quoted here-string terminates on `'@` at the
/// start of a line. Neutralise any such sequence inside user text so
/// the here-string always closes where we intend.
#[cfg(target_os = "windows")]
pub fn escape_ps_herestring(text: &str) -> String {
text.replace("'@", "' @")
}
#[cfg(target_os = "windows")]
fn spawn_windows(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
use base64::{engine::general_purpose::STANDARD, Engine};
let rate_int = win_rate(rate);
let safe_text = escape_ps_herestring(text);
let select = match voice {
// Regular single-quoted string (not here-string): doubled
// single quotes are the escape for a literal `'`.
Some(v) => format!("$s.SelectVoice('{}')", v.replace('\'', "''")),
None => String::new(),
};
let script = format!(
"Add-Type -AssemblyName System.Speech;\n\
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;\n\
$s.Rate = {rate_int};\n\
{select};\n\
$s.Speak(@'\n\
{safe_text}\n\
'@)"
);
let utf16: Vec<u8> = script
.encode_utf16()
.flat_map(|u| u.to_le_bytes())
.collect();
let encoded = STANDARD.encode(&utf16);
let mut cmd = Command::new("powershell.exe");
cmd.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded]);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn()
.map(Some)
.map_err(|e| format!("failed to spawn powershell: {e}"))
}
// ---------- Voice listing ----------
#[cfg(target_os = "linux")]
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
// spd-say's voice set depends on the active synth module and the
// list format isn't stable across distributions. For Phase 4 the
// picker renders "System default" only, which matches what
// `tts_speak` without a voice argument does anyway.
Ok(Vec::new())
}
#[cfg(target_os = "macos")]
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
let out = Command::new("say")
.arg("-v")
.arg("?")
.output()
.map_err(|e| format!("failed to query voices: {e}"))?;
if !out.status.success() {
return Err(format!("`say -v ?` exited with status {}", out.status));
}
let stdout = String::from_utf8_lossy(&out.stdout);
Ok(parse_macos_voices(&stdout))
}
#[cfg(target_os = "macos")]
pub fn parse_macos_voices(raw: &str) -> Vec<TtsVoice> {
raw.lines()
.filter_map(|line| {
let (prefix, _sample) = line.split_once('#')?;
let mut parts = prefix.split_whitespace();
let name = parts.next()?.to_string();
let locale = parts.next().map(str::to_string);
Some(TtsVoice {
id: name.clone(),
name,
language: locale,
})
})
.collect()
}
#[cfg(target_os = "windows")]
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
use base64::{engine::general_purpose::STANDARD, Engine};
let script = "Add-Type -AssemblyName System.Speech;\n\
(New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() |\n\
ForEach-Object { $_.VoiceInfo } |\n\
ForEach-Object { @{ Name = $_.Name; Culture = $_.Culture.Name } } |\n\
ConvertTo-Json -Compress";
let utf16: Vec<u8> = script
.encode_utf16()
.flat_map(|u| u.to_le_bytes())
.collect();
let encoded = STANDARD.encode(&utf16);
let out = Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded])
.output()
.map_err(|e| format!("failed to query voices: {e}"))?;
if !out.status.success() {
return Err(format!(
"PowerShell voice-list exited with status {}",
out.status
));
}
let stdout = String::from_utf8_lossy(&out.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let parsed: serde_json::Value =
serde_json::from_str(trimmed).map_err(|e| format!("voice-list JSON parse failed: {e}"))?;
// ConvertTo-Json emits a bare object for a single item and an
// array otherwise; normalise to always-array.
let items: Vec<serde_json::Value> = match parsed {
serde_json::Value::Array(a) => a,
v => vec![v],
};
Ok(items
.into_iter()
.filter_map(|v| {
let name = v.get("Name")?.as_str()?.to_string();
let culture = v
.get("Culture")
.and_then(|c| c.as_str())
.map(str::to_string);
Some(TtsVoice {
id: name.clone(),
name,
language: culture,
})
})
.collect())
}
// ---------- Tauri commands ----------
#[tauri::command]
pub fn tts_speak(
state: tauri::State<'_, TtsState>,
text: String,
rate: f32,
voice: Option<String>,
) -> Result<(), String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(());
}
// Cut any in-flight speech so a second tap starts cleanly rather
// than queueing on top.
kill_child(&state);
#[cfg(target_os = "linux")]
let spawned = spawn_linux(trimmed, rate, voice.as_deref())?;
#[cfg(target_os = "macos")]
let spawned = spawn_macos(trimmed, rate, voice.as_deref())?;
#[cfg(target_os = "windows")]
let spawned = spawn_windows(trimmed, rate, voice.as_deref())?;
if let Some(c) = spawned {
if let Ok(mut guard) = state.child.lock() {
*guard = Some(c);
}
}
Ok(())
}
#[tauri::command]
pub fn tts_stop(state: tauri::State<'_, TtsState>) -> Result<(), String> {
kill_child(&state);
#[cfg(target_os = "linux")]
stop_linux();
Ok(())
}
fn kill_child(state: &TtsState) {
if let Ok(mut guard) = state.child.lock() {
if let Some(mut c) = guard.take() {
let _ = c.kill();
let _ = c.wait();
}
}
}
#[tauri::command]
pub fn tts_list_voices() -> Result<Vec<TtsVoice>, String> {
list_voices_impl()
}
// ---------- Tests ----------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamp_rate_handles_nan() {
assert_eq!(clamp_rate(f32::NAN), 1.0);
}
#[test]
fn clamp_rate_bounds() {
assert_eq!(clamp_rate(0.1), 0.5);
assert_eq!(clamp_rate(0.5), 0.5);
assert_eq!(clamp_rate(1.0), 1.0);
assert_eq!(clamp_rate(2.0), 2.0);
assert_eq!(clamp_rate(3.0), 2.0);
}
#[cfg(target_os = "linux")]
#[test]
fn spd_rate_hits_anchors() {
assert_eq!(spd_rate(1.0), 0);
assert_eq!(spd_rate(2.0), 100);
assert_eq!(spd_rate(1.5), 50);
// 0.5 is only -50 with the simple linear slope — the asymmetry
// is documented in the `spd_rate` doc comment.
assert_eq!(spd_rate(0.5), -50);
}
#[cfg(target_os = "linux")]
#[test]
fn espeak_rate_stays_in_range() {
assert_eq!(espeak_rate(1.0), 175);
assert_eq!(espeak_rate(2.0), 350);
// 0.5 * 175 = 87.5 → rounds to 88 → still ≥ 80 floor.
assert_eq!(espeak_rate(0.5), 88);
// NaN → clamp_rate returns 1.0 → rate maps to 175.
assert_eq!(espeak_rate(f32::NAN), 175);
}
#[cfg(target_os = "macos")]
#[test]
fn say_rate_maps() {
assert_eq!(say_rate(1.0), 180);
assert_eq!(say_rate(2.0), 360);
assert_eq!(say_rate(0.5), 90);
}
#[cfg(target_os = "windows")]
#[test]
fn win_rate_bounds() {
assert_eq!(win_rate(0.5), -5);
assert_eq!(win_rate(1.0), 0);
assert_eq!(win_rate(2.0), 10);
}
#[cfg(target_os = "windows")]
#[test]
fn ps_herestring_terminator_is_broken() {
let input = "hello\n'@ evil";
let out = escape_ps_herestring(input);
assert!(!out.contains("'@"));
assert!(out.contains("' @"));
}
#[cfg(target_os = "macos")]
#[test]
fn parses_macos_voices() {
let raw = "Alex en_US # Most people recognize me\n\
Fred en_US # I sure like being inside\n";
let voices = parse_macos_voices(raw);
assert_eq!(voices.len(), 2);
assert_eq!(voices[0].name, "Alex");
assert_eq!(voices[0].language.as_deref(), Some("en_US"));
}
}

View File

@@ -130,6 +130,15 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
// Phase 5 rituals: autostart. The plugin registers JS-facing
// commands (isEnabled / enable / disable) that the Settings
// toggle and first-run prompt invoke directly — no bespoke
// Rust commands needed. `LaunchAgent` is the non-root macOS
// install path (per-user, no sudo).
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
// Remember size + position of every window across app restarts.
// Without this, secondary windows (preview overlay, task float,
// transcript viewer) open at whatever spot the compositor picks,
@@ -218,6 +227,7 @@ pub fn run() {
app.manage(commands::hotkey::HotkeyState::new());
app.manage(commands::audio::NativeCaptureState::new());
app.manage(commands::live::LiveTranscriptionState::default());
app.manage(commands::tts::TtsState::new());
app.manage(AppState {
whisper_engine: Arc::new(LocalEngine::new(EngineName::new("whisper"))),
@@ -234,7 +244,7 @@ pub fn run() {
// Runtime-warning banner: push CPU-feature + Vulkan-loader
// fallbacks to the frontend so Settings can render a one-line
// hint. No-ops on a fully-supported box.
crate::commands::models::emit_runtime_warnings(&app.handle());
crate::commands::models::emit_runtime_warnings(app.handle());
if let Err(e) = tray::setup(app) {
eprintln!("Failed to setup tray: {e}");
@@ -284,10 +294,21 @@ pub fn run() {
commands::tasks::complete_task_cmd,
commands::tasks::delete_task_cmd,
commands::tasks::uncomplete_task_cmd,
commands::tasks::set_task_energy_cmd,
commands::tasks::decompose_and_store,
commands::tasks::extract_tasks_from_transcript_cmd,
commands::tasks::list_subtasks_cmd,
commands::tasks::complete_subtask_cmd,
// HITL feedback (Phase 2 roadmap)
commands::feedback::record_feedback,
commands::feedback::list_feedback_examples_cmd,
// Read aloud (Phase 4 roadmap)
commands::tts::tts_speak,
commands::tts::tts_stop,
commands::tts::tts_list_voices,
// Rituals (Phase 5 roadmap)
commands::rituals::get_last_morning_triage,
commands::rituals::mark_morning_triage_shown,
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
commands::profiles::list_profiles_cmd,
commands::profiles::get_profile_cmd,

View File

@@ -1,13 +1,18 @@
use tauri::image::Image;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::Manager;
use tauri::{Emitter, Manager};
pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show = MenuItemBuilder::with_id("show", "Show Kon").build(app)?;
let status = MenuItemBuilder::with_id("status", "Ready")
.enabled(false)
.build(app)?;
// Phase 5: always-visible shortcut into the evening wind-down page.
// The page itself only renders once the user has enabled the
// ritual; clicking this when disabled is harmless (just takes them
// there), and Settings is where the toggle lives.
let wind_down = MenuItemBuilder::with_id("wind-down", "Evening wind-down").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app)
@@ -15,6 +20,8 @@ pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
.separator()
.item(&status)
.separator()
.item(&wind_down)
.separator()
.item(&quit)
.build()?;
@@ -34,6 +41,15 @@ pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let _ = window.set_focus();
}
}
"wind-down" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
// The frontend layout listens for this event and routes
// to the Phase 5 wind-down page.
let _ = app.emit("kon:open-wind-down", ());
}
"quit" => {
app.exit(0);
}

View File

@@ -0,0 +1,106 @@
<script lang="ts">
// Phase 3 — Energy tag chip. Cycles a task's energy level through
// the spec's four states: unset → High → Medium → Brain-Dead → unset.
//
// Visual discipline: when energy is unset, the chip renders at
// `group-hover` opacity only so untagged rows stay calm. Once set,
// the chip is always visible because the colour IS the signal for
// the match-my-energy sort.
//
// Colour choices borrow the existing design tokens:
// High → accent (warm, on-brand, attention-ready)
// Medium → warning (amber, unforced)
// Brain-Dead → text-tertiary (low-energy grey, not danger red —
// the brief is explicit that this state must not feel
// pathologised)
//
// Callers pass the task's current energy and a setter. This component
// owns no state — the task store is the source of truth.
import type { EnergyLevel } from "$lib/types/app";
import { Zap } from "lucide-svelte";
let {
energy = null as EnergyLevel | null,
onSelect,
size = "sm",
reduceMotion = false,
}: {
energy: EnergyLevel | null;
onSelect: (next: EnergyLevel | null) => void;
size?: "sm" | "md";
reduceMotion?: boolean;
} = $props();
// Cycle order lives here so the chip is the single authority on what
// "next" means. Tap once to tag, tap again to move up, tap past
// Brain-Dead to clear. Keyboard-equivalent via the <button> element.
const CYCLE: (EnergyLevel | null)[] = [null, "high", "medium", "brain_dead"];
function next(): EnergyLevel | null {
const idx = CYCLE.indexOf(energy);
return CYCLE[(idx + 1) % CYCLE.length];
}
function labelFor(level: EnergyLevel | null): string {
switch (level) {
case "high": return "High";
case "medium": return "Medium";
case "brain_dead": return "Brain-Dead";
default: return "No energy set";
}
}
let tooltip = $derived(
energy === null
? "Tag energy (click to set)"
: `Energy: ${labelFor(energy)} — click to change`
);
// Icon dimensions. `md` is the one used on the Tasks-page main rows;
// `sm` is for the compact WIP list rows and micro-step children.
let iconSize = $derived(size === "md" ? 13 : 10);
let chipSize = $derived(size === "md" ? "h-5" : "h-4");
</script>
<button
type="button"
class="energy-chip inline-flex items-center justify-center rounded-md border px-1 {chipSize} text-[10px] font-medium
{energy === null
? 'opacity-0 group-hover:opacity-100 text-text-tertiary border-border-subtle hover:border-accent hover:text-text-secondary'
: ''}
{energy === 'high'
? 'text-accent border-accent bg-accent/10'
: ''}
{energy === 'medium'
? 'text-warning border-warning bg-warning/10'
: ''}
{energy === 'brain_dead'
? 'text-text-tertiary border-border bg-hover'
: ''}"
onclick={() => onSelect(next())}
aria-label={tooltip}
title={tooltip}
data-energy={energy ?? 'unset'}
style={reduceMotion
? ''
: 'transition: opacity var(--duration-ui), color var(--duration-ui), border-color var(--duration-ui), background-color var(--duration-ui)'}
>
<Zap size={iconSize} aria-hidden="true" />
{#if energy !== null && size === "md"}
<span class="ml-1">{labelFor(energy)}</span>
{/if}
</button>
<style>
.energy-chip {
cursor: pointer;
line-height: 1;
font-family: var(--font-family-body);
}
.energy-chip:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
</style>

View File

@@ -0,0 +1,298 @@
<script lang="ts">
// Floating focus-timer overlay. Renders nothing when no timer is
// active. When a timer is running, pins a compact SVG progress ring
// to the top-right of the viewport with the remaining mm:ss in the
// centre. Completion plays a gentle chime, flashes a success ring
// for 3 s, then disappears. Cancel button appears on hover.
//
// Mounted once in +layout.svelte. Listens for `kon:start-timer`
// events from anywhere in the app (e.g. MicroSteps) and delegates
// to the focus-timer store.
//
// Design tokens used: --color-accent (mid-progress),
// --color-warning (final 15%), --color-success (flourish),
// --color-border (unfilled ring track). No literals, so the ring
// follows the sensory-zone theme switcher in Settings.
import { onMount, onDestroy } from "svelte";
import { X, Plus, ExternalLink } from "lucide-svelte";
import { focusTimer } from "$lib/stores/focusTimer.svelte.js";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
// Hide the "pop out" button inside the float window itself — opening
// a second float from a float would be silly and would re-mount the
// same component. Detect via URL rather than a prop so we do not
// have to thread context through every mount site.
let isSecondaryWindow = $state(false);
if (typeof window !== "undefined") {
isSecondaryWindow = window.location.pathname.startsWith("/float")
|| window.location.pathname.startsWith("/viewer");
}
function handlePopOut() {
// Mirror the button in TasksPage.svelte — opens the always-on-top
// Now list + pinned timer in one floating window.
if (!hasTauriRuntime()) return;
window.open("/float", "_blank", "width=380,height=520");
}
const RING_SIZE = 64;
const RING_STROKE = 5;
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
// Progress ring fills as time elapses. Stroke-dashoffset goes from
// circumference (empty) to 0 (full) — we want it the other way,
// because the UX is a shrinking-time disc: more elapsed = less
// ring visible. Render the remaining arc: dashoffset = circumference * progress.
let dashOffset = $derived(RING_CIRCUMFERENCE * focusTimer.progress);
function formatRemaining(ms: number): string {
const total = Math.ceil(ms / 1000);
const mins = Math.floor(total / 60);
const secs = total % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
// Colour shifts over the last 15% of the timer to cue "nearly done"
// without resorting to red (which the brief flags as anxiogenic for
// the target audience).
let ringColor = $derived.by(() => {
if (focusTimer.showingCompletionFlash) return "var(--color-success)";
if (focusTimer.progress >= 0.85) return "var(--color-warning)";
return "var(--color-accent)";
});
// Event handler: start a timer when any component fires `kon:start-timer`.
// Payload shape from MicroSteps.svelte and task row buttons:
// { taskId?: string, seconds: number, label?: string }
function handleStartEvent(evt: Event) {
const detail = (evt as CustomEvent).detail ?? {};
const seconds = Number(detail.seconds);
if (!Number.isFinite(seconds) || seconds <= 0) return;
focusTimer.start(seconds, {
taskId: detail.taskId ?? null,
label: detail.label ?? null,
});
}
onMount(() => {
window.addEventListener("kon:start-timer", handleStartEvent);
// Rehydrate any in-flight timer that survived a window close.
focusTimer.rehydrate();
});
onDestroy(() => {
window.removeEventListener("kon:start-timer", handleStartEvent);
});
function handleCancel() {
focusTimer.cancel();
}
function handleExtend() {
focusTimer.extend(60);
}
function handleDismissFlash() {
focusTimer.dismissCompletionFlash();
}
</script>
{#if focusTimer.active || focusTimer.showingCompletionFlash}
<div
class="focus-timer"
class:completed={focusTimer.showingCompletionFlash}
role="status"
aria-live="polite"
aria-label={focusTimer.label ?? "Focus timer"}
>
<div class="ring-wrap">
<svg
width={RING_SIZE}
height={RING_SIZE}
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
aria-hidden="true"
>
<!-- Track -->
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
stroke="var(--color-border)"
stroke-width={RING_STROKE}
/>
<!-- Progress (shrinking slice — full ring at start, none at end) -->
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
stroke={ringColor}
stroke-width={RING_STROKE}
stroke-linecap="round"
stroke-dasharray={RING_CIRCUMFERENCE}
stroke-dashoffset={dashOffset}
transform={`rotate(-90 ${RING_SIZE / 2} ${RING_SIZE / 2})`}
style="transition: stroke-dashoffset 250ms linear, stroke 400ms ease"
/>
</svg>
<div class="time" aria-hidden={focusTimer.showingCompletionFlash}>
{#if focusTimer.showingCompletionFlash}
<span class="done">done</span>
{:else}
{formatRemaining(focusTimer.remainingMs)}
{/if}
</div>
</div>
<div class="controls">
{#if focusTimer.showingCompletionFlash}
<button
class="icon-btn"
onclick={handleDismissFlash}
aria-label="Dismiss completion"
title="Dismiss"
>
<X size={14} aria-hidden="true" />
</button>
{:else}
<button
class="icon-btn"
onclick={handleExtend}
aria-label="Add one minute"
title="+1 min"
>
<Plus size={14} aria-hidden="true" />
</button>
{#if !isSecondaryWindow}
<button
class="icon-btn"
onclick={handlePopOut}
aria-label="Pop out timer + Now list into floating window"
title="Pop out (keeps timer + tasks on top)"
>
<ExternalLink size={14} aria-hidden="true" />
</button>
{/if}
<button
class="icon-btn"
onclick={handleCancel}
aria-label="Cancel timer"
title="Cancel"
>
<X size={14} aria-hidden="true" />
</button>
{/if}
</div>
{#if focusTimer.label}
<div class="label" aria-hidden="true">{focusTimer.label}</div>
{/if}
</div>
{/if}
<style>
.focus-timer {
position: fixed;
top: 52px;
right: 16px;
z-index: 40;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px 6px 6px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
font-family: var(--font-family-body);
font-variant-numeric: tabular-nums;
transition: opacity 200ms ease, transform 200ms ease;
}
.focus-timer.completed {
border-color: var(--color-success);
}
.ring-wrap {
position: relative;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.time {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
color: var(--color-text);
}
.time .done {
font-size: 11px;
color: var(--color-success);
font-family: var(--font-family-display);
letter-spacing: 0.02em;
}
.controls {
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
transition: opacity 180ms ease;
}
.focus-timer:hover .controls,
.focus-timer:focus-within .controls,
.focus-timer.completed .controls {
opacity: 1;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.icon-btn:hover {
background: var(--color-hover);
color: var(--color-text);
}
.icon-btn:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.label {
max-width: 140px;
font-size: 12px;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global([data-reduce-motion="true"]) .focus-timer circle {
transition: none !important;
}
</style>

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { ListTree, Check, Timer, Loader2 } from 'lucide-svelte';
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
import { profilesStore } from '$lib/stores/profiles.svelte.ts';
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
let { parentTaskId, reduceMotion = false } = $props();
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
interface Subtask {
id: string;
@@ -15,6 +17,22 @@
let error = $state('');
let decomposing = $state(false);
// Per-step UI state. Keyed by subtask id so we never lose state when
// the list reorders. Values:
// rating[id] — 1 | -1 — the thumbs vote the user gave this session
// editing[id] — true while the user is editing the step text
// draft[id] — the in-flight edit value before save
let rating = $state<Record<string, 1 | -1 | undefined>>({});
let editing = $state<Record<string, boolean>>({});
let draft = $state<Record<string, string>>({});
// Monotonic token per-step. Each time the user kicks off a save we
// bump the token; the pending save remembers its token and only
// rolls back if the failure belongs to the still-current edit.
// Without this, a slow first save that eventually fails will stomp
// a faster second save that already committed.
let saveToken = $state<Record<string, number>>({});
async function loadSubtasks() {
loading = true;
error = '';
@@ -31,7 +49,10 @@
decomposing = true;
error = '';
try {
subtasks = await invoke<Subtask[]>('decompose_and_store', { parentTaskId });
subtasks = await invoke<Subtask[]>('decompose_and_store', {
parentTaskId,
profileId: profilesStore.activeProfileId,
});
} catch (e) {
error = String(e);
} finally {
@@ -53,6 +74,101 @@
}));
}
// --- HITL feedback --------------------------------------------------------
//
// All three paths (thumbs up, thumbs down, correction-via-edit) route
// into the same `record_feedback` command. The parent task text is the
// "input" the AI was given, so it travels in context_json so the prompt
// builder can reconstruct the (input, good-output) pair.
function feedbackContextJson() {
return JSON.stringify({ input: parentTaskText ?? '' });
}
async function recordThumb(step: Subtask, ratingValue: 1 | -1) {
// Toggle: if the user already voted the same way, clear it (record
// rating 0 means correction, not a thumb-off — we just skip the
// re-record and drop the local highlight). Unvoting isn't stored;
// the audit trail stays immutable.
if (rating[step.id] === ratingValue) {
const next = { ...rating };
delete next[step.id];
rating = next;
return;
}
rating = { ...rating, [step.id]: ratingValue };
try {
await invoke('record_feedback', {
input: {
targetType: 'microstep',
targetId: step.id,
rating: ratingValue,
originalText: step.text,
correctedText: null,
contextJson: feedbackContextJson(),
profileId: profilesStore.activeProfileId,
},
});
} catch (_) { /* feedback capture is best-effort, never fatal */ }
}
function startEdit(step: Subtask) {
editing = { ...editing, [step.id]: true };
draft = { ...draft, [step.id]: step.text };
}
function cancelEdit(stepId: string) {
const nextE = { ...editing }; delete nextE[stepId]; editing = nextE;
const nextD = { ...draft }; delete nextD[stepId]; draft = nextD;
}
async function saveEdit(step: Subtask) {
const next = (draft[step.id] ?? '').trim();
cancelEdit(step.id);
if (!next || next === step.text) return;
const original = step.text;
// Update in-memory first so the UI is snappy; roll back if the
// persistence call fails so we never show stale-but-different text.
// The monotonic `myToken` guards against a stale rollback stomping
// a fresher successful save that landed while we were waiting.
const myToken = (saveToken[step.id] ?? 0) + 1;
saveToken = { ...saveToken, [step.id]: myToken };
const idx = subtasks.findIndex(s => s.id === step.id);
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], text: next };
try {
await invoke('update_task_cmd', {
id: step.id,
patch: { text: next },
});
// Record correction as the highest-value feedback signal.
await invoke('record_feedback', {
input: {
targetType: 'microstep',
targetId: step.id,
rating: 0,
originalText: original,
correctedText: next,
contextJson: feedbackContextJson(),
profileId: profilesStore.activeProfileId,
},
}).catch(() => {});
} catch (_) {
// Only roll back if our token is still the most recent — a later
// edit that already succeeded must not be overwritten.
if (saveToken[step.id] === myToken) {
const rollbackIdx = subtasks.findIndex(s => s.id === step.id);
if (rollbackIdx >= 0) {
subtasks[rollbackIdx] = { ...subtasks[rollbackIdx], text: original };
}
}
}
}
function handleEditKeydown(evt: KeyboardEvent, step: Subtask) {
if (evt.key === 'Enter') { evt.preventDefault(); saveEdit(step); }
else if (evt.key === 'Escape') { evt.preventDefault(); cancelEdit(step.id); }
}
$effect(() => {
if (parentTaskId) loadSubtasks();
});
@@ -97,10 +213,70 @@
<Check size={9} aria-hidden="true" />
{/if}
</button>
<span class="text-[12px] flex-1 min-w-0 {step.done ? 'line-through text-text-tertiary' : 'text-text-secondary'} truncate">
{step.text}
</span>
{#if !step.done}
{#if editing[step.id]}
<!-- svelte-ignore a11y_autofocus — deliberate: inline edit
is user-initiated and focus must land on the input to
match the UX pattern users expect from any task app. -->
<input
type="text"
bind:value={draft[step.id]}
onkeydown={(e) => handleEditKeydown(e, step)}
onblur={() => saveEdit(step)}
class="text-[12px] flex-1 min-w-0 bg-bg-input border border-accent rounded px-1.5 py-0.5 text-text focus:outline-none"
autofocus
data-no-transition
/>
{:else}
<button
type="button"
class="text-[12px] flex-1 min-w-0 {step.done ? 'line-through text-text-tertiary' : 'text-text-secondary'} truncate text-left cursor-text bg-transparent border-0 p-0"
ondblclick={() => !step.done && startEdit(step)}
disabled={step.done}
aria-label="Double-click to edit this step"
title="Double-click to edit"
>{step.text}</button>
{/if}
{#if !step.done && !editing[step.id]}
<!-- HITL feedback: thumbs vote + pencil edit. All three
route into record_feedback and feed the prompt-conditioning
loop. See docs/roadmap/2026-04-23-... Phase 2. -->
<button
class="opacity-0 group-hover:opacity-100 p-0.5 text-text-tertiary hover:text-success
{rating[step.id] === 1 ? '!opacity-100 text-success' : ''}"
onclick={() => recordThumb(step, 1)}
aria-label={rating[step.id] === 1 ? 'Remove thumbs up' : 'Thumbs up — this is a good step'}
title="Thumbs up — train the model on this style"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui), color var(--duration-ui)'}
>
<ThumbsUp size={10} aria-hidden="true" />
</button>
<button
class="opacity-0 group-hover:opacity-100 p-0.5 text-text-tertiary hover:text-danger
{rating[step.id] === -1 ? '!opacity-100 text-danger' : ''}"
onclick={() => recordThumb(step, -1)}
aria-label={rating[step.id] === -1 ? 'Remove thumbs down' : 'Thumbs down — this misses the mark'}
title="Thumbs down — avoid this style"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui), color var(--duration-ui)'}
>
<ThumbsDown size={10} aria-hidden="true" />
</button>
<button
class="opacity-0 group-hover:opacity-100 p-0.5 text-text-tertiary hover:text-accent"
onclick={() => startEdit(step)}
aria-label="Edit this step (the correction trains future suggestions)"
title="Edit — this is the strongest training signal"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
>
<Pencil size={10} aria-hidden="true" />
</button>
<span
class="opacity-0 group-hover:opacity-100"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
>
<SpeakerButton text={step.text} label="Read this step aloud" size={10} />
</span>
<button
class="opacity-0 group-hover:opacity-100 flex items-center gap-1 text-[10px] text-text-tertiary hover:text-accent"
onclick={() => startTimer(step.id)}

View File

@@ -0,0 +1,274 @@
<script lang="ts">
// @ts-nocheck
// Phase 5 morning triage. Surfaces a calm "pick up to three for today"
// modal on the first launch-of-day once the user-set trigger time has
// passed. Evidence-based "rule of 3" per Life Skills Advocate / Aspire
// Therapy ADHD routine literature: externalise the daily choice and
// cap it at 3 to protect working memory (Sweller cognitive-load theory,
// Barkley's point-of-performance principle).
//
// Copy audit: no "overdue", no "failed", no subtractive framing (RSD).
//
// Triggering: runs a lightweight check on mount and when the page
// regains focus. Only one modal per calendar day regardless of how
// many times the app is restarted.
import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { hasTauriRuntime } from '$lib/utils/runtime.js';
import { settings } from '$lib/stores/page.svelte.js';
import { toasts } from '$lib/stores/toasts.svelte.js';
interface TriageTask {
id: string;
text: string;
bucket: string;
done: boolean;
createdAt: string;
}
let open = $state(false);
let loading = $state(false);
let tasks = $state<TriageTask[]>([]);
let selected = $state<Set<string>>(new Set());
let tooManyFlash = $state(false);
let applying = $state(false);
let focusHandler: (() => void) | null = null;
function todayKey(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// Parse a HH:MM string into minutes-since-midnight. Falls back to
// 08:00 on malformed input rather than throwing — rituals must never
// break the app shell.
function parseTriggerMinutes(hhmm: string | undefined): number {
if (typeof hhmm !== 'string') return 8 * 60;
const match = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
if (!match) return 8 * 60;
const h = Math.max(0, Math.min(23, parseInt(match[1], 10)));
const m = Math.max(0, Math.min(59, parseInt(match[2], 10)));
return h * 60 + m;
}
function currentMinutes(): number {
const d = new Date();
return d.getHours() * 60 + d.getMinutes();
}
function isBeforeToday(createdAt: string): boolean {
// Task `createdAt` comes from SQLite as ISO-8601 UTC. Compare the
// local-time date portion so a task made last night locally counts
// as "yesterday" regardless of the UTC offset.
const d = new Date(createdAt);
if (Number.isNaN(d.getTime())) return false;
const local = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return local < todayKey();
}
async function maybeShow() {
if (open || applying) return;
if (!settings.ritualsMorning) return;
if (!hasTauriRuntime()) return;
if (currentMinutes() < parseTriggerMinutes(settings.ritualsMorningTime)) return;
let lastShown: string | null = null;
try {
lastShown = await invoke<string | null>('get_last_morning_triage');
} catch {
// Non-fatal: if we can't read the sentinel, treat as never-shown.
}
if (lastShown === todayKey()) return;
loading = true;
try {
const all = await invoke<TriageTask[]>('list_tasks_cmd');
tasks = all.filter(
(t) => !t.done && t.bucket !== 'today' && isBeforeToday(t.createdAt),
);
} catch {
tasks = [];
} finally {
loading = false;
}
if (tasks.length === 0) {
// Nothing to triage — record the shown sentinel anyway so we
// don't re-check the DB every focus event today.
try { await invoke('mark_morning_triage_shown', { date: todayKey() }); } catch {}
return;
}
selected = new Set();
open = true;
}
function toggle(taskId: string) {
if (selected.has(taskId)) {
const next = new Set(selected);
next.delete(taskId);
selected = next;
return;
}
if (selected.size >= 3) {
tooManyFlash = true;
setTimeout(() => { tooManyFlash = false; }, 1800);
return;
}
const next = new Set(selected);
next.add(taskId);
selected = next;
}
async function skipForToday() {
applying = true;
try {
await invoke('mark_morning_triage_shown', { date: todayKey() });
} catch (err) {
toasts.warn('Could not save triage state', String(err));
} finally {
applying = false;
open = false;
}
}
async function startTheDay() {
if (selected.size === 0) return;
applying = true;
try {
for (const id of selected) {
try {
await invoke('update_task_cmd', {
id,
patch: { bucket: 'today' },
});
} catch (err) {
// Continue the loop — surface a single toast at the end rather
// than one per failure, so the user isn't drowned in errors.
console.warn('Triage: failed to move task', id, err);
}
}
await invoke('mark_morning_triage_shown', { date: todayKey() });
} catch (err) {
toasts.warn('Could not save triage state', String(err));
} finally {
applying = false;
open = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') {
e.preventDefault();
skipForToday();
}
}
onMount(() => {
maybeShow();
focusHandler = () => maybeShow();
window.addEventListener('focus', focusHandler);
});
onDestroy(() => {
if (focusHandler) window.removeEventListener('focus', focusHandler);
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="triage-title"
>
<div class="bg-bg-elevated border border-border rounded-2xl shadow-2xl max-w-[480px] w-[90vw] max-h-[80vh] flex flex-col">
<div class="px-6 pt-6 pb-3">
<h2 id="triage-title" class="font-display text-[22px] italic text-text">Pick up to three for today</h2>
<p class="text-[12px] text-text-secondary mt-1">
Yesterday's open items. The rest can wait.
</p>
</div>
<div class="flex-1 overflow-y-auto px-6 pb-3 min-h-0">
{#if loading}
<p class="text-[12px] text-text-tertiary py-6 text-center">Loading your list…</p>
{:else}
<ul class="flex flex-col gap-1.5">
{#each tasks as task (task.id)}
{@const picked = selected.has(task.id)}
<li>
<button
type="button"
class="w-full text-left flex items-start gap-3 px-3 py-2 rounded-lg border transition-colors
{picked
? 'bg-accent/10 border-accent text-text'
: 'bg-bg-input border-border-subtle text-text-secondary hover:border-border'}"
onclick={() => toggle(task.id)}
aria-pressed={picked}
>
<span
class="mt-0.5 w-4 h-4 rounded-sm border flex items-center justify-center flex-shrink-0
{picked ? 'bg-accent border-accent text-white' : 'border-border'}"
aria-hidden="true"
>
{#if picked}
<svg viewBox="0 0 24 24" class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="3">
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</span>
<span class="text-[13px] leading-snug">{task.text}</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div class="px-6 pb-5 pt-2">
<p
class="text-[11px] mb-3 min-h-[16px] transition-colors
{tooManyFlash ? 'text-warning' : 'text-text-tertiary'}"
aria-live="polite"
>
{#if tooManyFlash}
Just three for today. Unpick one to swap.
{:else if selected.size > 0}
{selected.size} picked · room for {3 - selected.size} more
{:else}
Pick 1, 2, or 3.
{/if}
</p>
<div class="flex items-center justify-between gap-3">
<button
type="button"
class="px-3 py-2 text-[12px] text-text-tertiary hover:text-text"
onclick={skipForToday}
disabled={applying}
>
Skip for today
</button>
<button
type="button"
class="px-4 py-2 rounded-lg text-[12px] font-medium transition-colors
{selected.size >= 1 && !applying
? 'bg-accent text-white hover:bg-accent-hover'
: 'bg-bg-input text-text-tertiary cursor-not-allowed'}"
onclick={startTheDay}
disabled={selected.size === 0 || applying}
>
{applying ? 'Saving…' : 'Start the day'}
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { Volume2, Square } from 'lucide-svelte';
import { settings } from '$lib/stores/page.svelte.js';
import { activeSpeaker, setActiveSpeaker } from '$lib/stores/speaker.svelte.ts';
let {
text,
label = 'Read aloud',
size = 12,
}: { text: string; label?: string; size?: number } = $props();
// A stable id per mounted button. `crypto.randomUUID` is available
// in any runtime recent enough to host Tauri's webview.
const instanceId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `sp_${Math.random().toString(36).slice(2)}`;
let revertTimer: ReturnType<typeof setTimeout> | null = null;
const speaking = $derived(activeSpeaker.id === instanceId);
async function toggle() {
if (speaking) {
await stop();
} else {
await start();
}
}
async function start() {
// If a different button is active, cancel its speech first so we
// don't get two synths talking over each other.
if (activeSpeaker.id !== null && activeSpeaker.id !== instanceId) {
try {
await invoke('tts_stop');
} catch {
// Best-effort: nothing to gain from surfacing a stop error.
}
}
setActiveSpeaker(instanceId);
try {
await invoke('tts_speak', {
text,
rate: settings.ttsRate,
voice: settings.ttsVoice ?? null,
});
} catch {
setActiveSpeaker(null);
return;
}
scheduleRevert();
}
async function stop() {
clearRevert();
setActiveSpeaker(null);
try {
await invoke('tts_stop');
} catch {
// Best-effort.
}
}
// No platform exposes a "speech finished" signal cheaply, so we
// estimate duration from word count and revert the icon when it
// elapses. 150 wpm is a comfortable pace for British-English; the
// user's rate slider shortens or lengthens the estimate.
function scheduleRevert() {
clearRevert();
const words = text.trim().split(/\s+/).filter(Boolean).length;
const baseSeconds = Math.max(2, Math.min(600, (words / 150) * 60));
const rate = settings.ttsRate > 0 ? settings.ttsRate : 1.0;
const ms = Math.round((baseSeconds * 1000) / rate);
revertTimer = setTimeout(() => {
if (activeSpeaker.id === instanceId) {
setActiveSpeaker(null);
}
}, ms);
}
function clearRevert() {
if (revertTimer !== null) {
clearTimeout(revertTimer);
revertTimer = null;
}
}
onDestroy(() => {
clearRevert();
if (activeSpeaker.id === instanceId) {
setActiveSpeaker(null);
invoke('tts_stop').catch(() => {});
}
});
</script>
<button
type="button"
class="p-0.5 text-text-tertiary hover:text-accent {speaking ? '!text-accent' : ''}"
onclick={toggle}
aria-label={speaking ? 'Stop reading aloud' : label}
title={speaking ? 'Stop reading aloud' : label}
>
{#if speaking}
<Square {size} aria-hidden="true" />
{:else}
<Volume2 {size} aria-hidden="true" />
{/if}
</button>

View File

@@ -1,7 +1,14 @@
<script lang="ts">
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
import { tasks, addTask, completeTask, uncompleteTask, deleteTask, setTaskEnergy } from '$lib/stores/page.svelte.js';
import MicroSteps from '$lib/components/MicroSteps.svelte';
import { ChevronDown, ChevronRight } from 'lucide-svelte';
import EnergyChip from '$lib/components/EnergyChip.svelte';
import { ChevronDown, ChevronRight, Timer } from 'lucide-svelte';
function startFocusTimer(task: { id: string; text: string }) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { taskId: task.id, seconds: 300, label: task.text }
}));
}
let { wipLimit = 3 } = $props();
@@ -68,6 +75,22 @@
aria-label="Complete task"
></button>
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
<!-- Energy chip (Phase 3) — compact, reveals on hover when unset -->
<EnergyChip
energy={task.energy}
onSelect={(next) => setTaskEnergy(task.id, next)}
size="sm"
/>
<!-- 5-min focus timer — the "just-start" button from the brief -->
<button
class="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-accent"
onclick={() => startFocusTimer(task)}
aria-label="Start 5-minute focus timer for this task"
title="Start 5-minute focus timer"
style="transition: opacity var(--duration-ui)"
>
<Timer size={12} aria-hidden="true" />
</button>
<!-- Expand/collapse micro-steps toggle -->
<button
class="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-accent"
@@ -91,7 +114,7 @@
</div>
<!-- Micro-steps panel (expanded) -->
{#if expandedTaskIds.has(task.id)}
<MicroSteps parentTaskId={task.id} />
<MicroSteps parentTaskId={task.id} parentTaskText={task.text} />
{/if}
</div>
{/each}

View File

@@ -17,6 +17,7 @@
import { FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
import { Mic, Loader2, SquareCheck, AlertTriangle } from 'lucide-svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import { bionicReading } from '$lib/actions/bionicReading.js';
import { measurePreWrap } from '$lib/utils/textMeasure.js';
@@ -487,7 +488,10 @@
if (settings.aiTier === "tasks" && llmLoaded) {
markGenerating("Extracting tasks");
try {
const items = await invoke("extract_tasks_from_transcript_cmd", { transcript: text });
const items = await invoke("extract_tasks_from_transcript_cmd", {
transcript: text,
profileId: profilesStore.activeProfileId,
});
markGenerationDone(true);
return items.map((taskText) => ({ text: taskText }));
} catch (err) {
@@ -1066,6 +1070,9 @@
<span class="text-[11px] text-text-tertiary">
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
</span>
{#if transcript.trim()}
<SpeakerButton text={transcript} label="Read transcript aloud" size={12} />
{/if}
</div>
</div>
</Card>

View File

@@ -2,9 +2,10 @@
// @ts-nocheck
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { page, settings } from "$lib/stores/page.svelte.js";
import { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
import UnicodeSpinner from "$lib/components/UnicodeSpinner.svelte";
import { Download, CheckCircle } from 'lucide-svelte';
import { toasts } from "$lib/stores/toasts.svelte.js";
import { Download, CheckCircle, Sunrise, Moon, Play } from 'lucide-svelte';
let systemInfo = $state(null);
let models = $state([]);
@@ -75,8 +76,15 @@
}
ready = true;
// Brief "Ready" beat, then onto the rituals prompt (or straight
// to dictation if the user has already seen it).
setTimeout(() => {
page.current = "dictation";
if (settings.ritualsPromptSeen) {
page.current = "dictation";
} else {
ready = false;
ritualsStep = "morning";
}
}, 1500);
} catch (e) {
error = `Download failed: ${e}`;
@@ -87,7 +95,63 @@
}
}
// Phase 5: forced-choice rituals + autostart prompts. Research on
// libertarian-paternalism nudges (Thaler/Sunstein) says defaults
// drive uptake, but the ADHD target audience is sensitive to
// parental framing — so we present explicit opt-in with calm copy
// rather than defaulting anything on.
type RitualsStep = "idle" | "morning" | "evening" | "autostart" | "done";
let ritualsStep = $state<RitualsStep>("idle");
let autostartApplying = $state(false);
async function answerMorning(yes: boolean) {
settings.ritualsMorning = yes;
saveSettings();
ritualsStep = "evening";
}
async function answerEvening(yes: boolean) {
settings.ritualsEvening = yes;
saveSettings();
ritualsStep = "autostart";
}
async function answerAutostart(yes: boolean) {
autostartApplying = true;
try {
const plugin = await import("@tauri-apps/plugin-autostart");
if (yes) {
await plugin.enable();
settings.launchAtLogin = true;
} else {
// Don't call disable() on a fresh install — there's nothing to
// disable, and some platforms treat "disable when unset" as an
// error. Just record the choice.
settings.launchAtLogin = false;
}
} catch (err) {
toasts.warn("Could not update autostart", String(err));
} finally {
autostartApplying = false;
settings.ritualsPromptSeen = true;
saveSettings();
ritualsStep = "done";
setTimeout(() => { page.current = "dictation"; }, 900);
}
}
function skipRituals() {
settings.ritualsPromptSeen = true;
saveSettings();
page.current = "dictation";
}
function skipSetup() {
// Skipping model download still marks the rituals prompt as seen —
// the user chose to bypass the walk-through; they can find rituals
// in Settings when they're ready.
settings.ritualsPromptSeen = true;
saveSettings();
page.current = "dictation";
}
@@ -107,7 +171,84 @@
<div class="text-center">
<CheckCircle size={40} strokeWidth={1.5} class="text-success mx-auto" />
<h2 class="text-xl font-medium text-text mt-4">Ready to go</h2>
<p class="text-sm text-text-secondary mt-2">Press the button. Start talking. That's it.</p>
<p class="text-sm text-text-secondary mt-2">A few quick choices, then you're in.</p>
</div>
{:else if ritualsStep === "morning"}
<div class="w-full max-w-md mx-auto text-center">
<Sunrise size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
<h2 class="text-xl font-medium text-text">Morning triage?</h2>
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
On the first launch of the day, a gentle modal shows yesterday's open items and asks you to pick up to three for today. The rest can wait.
</p>
<p class="text-[11px] text-text-tertiary mt-3">Off by default. You can change your mind any time in Settings.</p>
<div class="flex items-center justify-center gap-3 mt-6">
<button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
onclick={() => answerMorning(false)}
>No thanks</button>
<button
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
onclick={() => answerMorning(true)}
>Yes, turn it on</button>
</div>
<button
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
onclick={skipRituals}
>Skip all these questions</button>
</div>
{:else if ritualsStep === "evening"}
<div class="w-full max-w-md mx-auto text-center">
<Moon size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
<h2 class="text-xl font-medium text-text">Evening wind-down?</h2>
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
A reflective page you can open when you want to close the day. Shows what you finished, names the open loops, then gets out of the way. Never scheduled, never nagging.
</p>
<p class="text-[11px] text-text-tertiary mt-3">Off by default. Always opt-in.</p>
<div class="flex items-center justify-center gap-3 mt-6">
<button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
onclick={() => answerEvening(false)}
>No thanks</button>
<button
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
onclick={() => answerEvening(true)}
>Yes, turn it on</button>
</div>
<button
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
onclick={skipRituals}
>Skip the rest</button>
</div>
{:else if ritualsStep === "autostart"}
<div class="w-full max-w-md mx-auto text-center">
<Play size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
<h2 class="text-xl font-medium text-text">Launch Corbie at login?</h2>
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
So Corbie is already there when you need it — especially useful if you said yes to morning triage. Uses your OS's standard autostart. No background tricks, no telemetry.
</p>
<p class="text-[11px] text-text-tertiary mt-3">You can change this any time in Settings.</p>
<div class="flex items-center justify-center gap-3 mt-6">
<button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
onclick={() => answerAutostart(false)}
disabled={autostartApplying}
>No thanks</button>
<button
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover disabled:opacity-60"
onclick={() => answerAutostart(true)}
disabled={autostartApplying}
>{autostartApplying ? 'Saving…' : 'Yes, launch at login'}</button>
</div>
</div>
{:else if ritualsStep === "done"}
<div class="text-center">
<CheckCircle size={40} strokeWidth={1.5} class="text-success mx-auto" />
<h2 class="text-xl font-medium text-text mt-4">All set</h2>
<p class="text-sm text-text-secondary mt-2">Press the button. Start talking.</p>
</div>
{:else if downloading}

View File

@@ -633,6 +633,85 @@
}
}
// Phase 4 Read Page Aloud. Voices are lazy-loaded on first open so
// Settings doesn't pay the `say -v ?` / PowerShell cost on every
// mount. An empty list renders as "System default" only.
let ttsVoices = $state([]);
let ttsVoicesLoaded = $state(false);
let ttsVoicesLoading = $state(false);
let ttsVoicesError = $state("");
async function refreshTtsVoices() {
if (ttsVoicesLoading) return;
ttsVoicesLoading = true;
ttsVoicesError = "";
try {
ttsVoices = await invoke("tts_list_voices");
ttsVoicesLoaded = true;
} catch (err) {
ttsVoicesError = String(err);
} finally {
ttsVoicesLoading = false;
}
}
async function toggleReadAloudSection() {
openSection = openSection === 'readAloud' ? null : 'readAloud';
if (openSection === 'readAloud' && !ttsVoicesLoaded) {
await refreshTtsVoices();
}
}
async function testReadAloudVoice() {
try {
await invoke("tts_speak", {
text: "This is Corbie reading aloud.",
rate: settings.ttsRate,
voice: settings.ttsVoice ?? null,
});
} catch (err) {
toasts.warn("Could not read aloud", String(err));
}
}
// Phase 5 rituals. Autostart state mirrors the OS-level entry managed
// by tauri-plugin-autostart; reading via invoke keeps the toggle
// honest even if the user has edited their .desktop file manually.
let autostartSyncing = $state(false);
async function setLaunchAtLogin(nextOn: boolean) {
autostartSyncing = true;
try {
const plugin = await import("@tauri-apps/plugin-autostart");
if (nextOn) {
await plugin.enable();
} else {
await plugin.disable();
}
settings.launchAtLogin = nextOn;
} catch (err) {
toasts.warn("Could not update autostart", String(err));
// Re-read to correct the UI if we failed halfway.
try {
const plugin = await import("@tauri-apps/plugin-autostart");
settings.launchAtLogin = await plugin.isEnabled();
} catch { /* best-effort */ }
} finally {
autostartSyncing = false;
}
}
async function syncAutostartFromOs() {
try {
const plugin = await import("@tauri-apps/plugin-autostart");
settings.launchAtLogin = await plugin.isEnabled();
} catch { /* best-effort on browser / first load */ }
}
function openWindDown() {
page.current = "shutdown";
}
onMount(async () => {
try {
await refreshRuntimeCapabilities();
@@ -651,6 +730,10 @@
// the user opens the Audio section.
refreshAudioDevices();
// Phase 5: read the live OS-level autostart state so the toggle
// reflects reality rather than last-saved intent.
syncAutostartFromOs();
// Vocabulary is loaded reactively via the $effect that tracks the
// active profile id (set once profilesStore.load() resolves in the
// root layout). No eager fetch needed here.
@@ -1355,6 +1438,171 @@
{/if}
</div>
<!-- Rituals (Phase 5 roadmap) -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'rituals' ? null : 'rituals'}
>
<h3 class="font-display text-[18px] italic text-text">Rituals</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'rituals' ? '' : '+'}</span>
</button>
{#if openSection === 'rituals'}
<div class="px-5 pb-5 animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4">
All off by default. Rituals only appear when you ask for them.
</p>
<Toggle
bind:checked={settings.ritualsMorning}
label="Morning triage"
description="On the first launch of the day after your set time, show a gentle pick-three modal drawn from open tasks. Skip any day without penalty."
/>
{#if settings.ritualsMorning}
<div class="pl-1 py-3 animate-fade-in">
<label for="triage-time" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
Earliest triage time
</label>
<input
id="triage-time"
type="time"
class="bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text focus:border-accent focus:outline-none"
bind:value={settings.ritualsMorningTime}
/>
<p class="text-[11px] text-text-tertiary mt-2">
ADHD sleep inertia is intense for the first 3045 minutes after waking. Pick a time when you're genuinely ready to decide.
</p>
</div>
{/if}
<Toggle
bind:checked={settings.ritualsEvening}
label="Evening wind-down"
description="A reflective page you can open when you want to close the day. Not scheduled, never nagging."
/>
{#if settings.ritualsEvening}
<div class="pl-1 py-3 animate-fade-in">
<button
type="button"
class="px-3 py-2 rounded-lg bg-bg-elevated border border-border text-[12px] text-text hover:border-accent"
onclick={openWindDown}
>
Open wind-down now
</button>
</div>
{/if}
<div class="mt-4 pt-4 border-t border-border-subtle">
<!-- One-way flow: click → OS call → state update. Can't use
the standard Toggle because its bind:checked would
race the autostart invoke and let the UI lie during
the round-trip. -->
<div class="flex items-start gap-3 py-2.5">
<button
class="relative mt-0.5 w-[38px] min-w-[38px] h-[22px] rounded-full flex-shrink-0
{settings.launchAtLogin ? 'bg-accent shadow-[0_0_8px_rgba(232,168,124,0.25)]' : 'bg-bg-elevated'}
active:scale-95 disabled:opacity-60"
style="transition-duration: var(--duration-ui)"
onclick={() => setLaunchAtLogin(!settings.launchAtLogin)}
disabled={autostartSyncing}
role="switch"
aria-checked={settings.launchAtLogin}
aria-label="Launch Corbie at login"
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm
{settings.launchAtLogin ? 'translate-x-[16px]' : 'translate-x-0'}"
style="transition: transform var(--duration-ui) cubic-bezier(0.34, 1.56, 0.64, 1)"
></span>
</button>
<div class="flex-1 min-w-0">
<p class="text-[13px] text-text leading-tight">Launch Corbie at login</p>
<p class="text-[11px] text-text-tertiary mt-0.5 leading-snug">
So Corbie is already there at the start of the day. Uses your OS's standard autostart — no background tricks.
</p>
{#if autostartSyncing}
<p class="text-[11px] text-text-tertiary mt-1">Updating…</p>
{/if}
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Read aloud (Phase 4 roadmap) -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={toggleReadAloudSection}
>
<h3 class="font-display text-[18px] italic text-text">Read aloud</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'readAloud' ? '' : '+'}</span>
</button>
{#if openSection === 'readAloud'}
<div class="px-5 pb-5 animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4">
Uses your operating system's built-in voices. No audio leaves the machine.
</p>
<div class="mb-4">
<label for="tts-voice" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Voice</label>
<select
id="tts-voice"
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
value={settings.ttsVoice ?? ""}
onchange={(e) => { settings.ttsVoice = e.currentTarget.value || null; }}
disabled={ttsVoicesLoading}
>
<option value="">System default</option>
{#each ttsVoices as voice (voice.id)}
<option value={voice.id}>
{voice.name}{#if voice.language} · {voice.language}{/if}
</option>
{/each}
</select>
{#if ttsVoicesError}
<p class="text-[11px] text-danger mt-2">{ttsVoicesError}</p>
{:else if ttsVoicesLoaded && ttsVoices.length === 0}
<p class="text-[11px] text-text-tertiary mt-2">
No additional voices reported by the system synth. Install extra voices through your OS accessibility settings.
</p>
{/if}
</div>
<div class="mb-4">
<label for="tts-rate" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
Rate · {settings.ttsRate.toFixed(1)}×
</label>
<input
id="tts-rate"
type="range"
min="0.5"
max="2.0"
step="0.1"
class="w-full accent-accent"
bind:value={settings.ttsRate}
/>
<div class="flex justify-between text-[10px] text-text-tertiary mt-1">
<span>Slower</span>
<span>Normal</span>
<span>Faster</span>
</div>
</div>
<button
type="button"
class="px-3 py-2 rounded-lg bg-bg-elevated border border-border text-[12px] text-text hover:border-accent"
onclick={testReadAloudVoice}
>
Test voice
</button>
</div>
{/if}
</div>
<!-- AI Assistant -->
<div class="border-b border-border-subtle">
<button

View File

@@ -0,0 +1,169 @@
<script lang="ts">
// @ts-nocheck
// Phase 5 evening wind-down. A reflective (not transactional) page
// users trigger manually when they want to close the working day.
//
// Newport-style shutdown ritual: mechanical closure + physical reset +
// intentional cue. Research on psychological detachment shows this
// reduces evening rumination by ~40% (Simply Psychology / Cal Newport).
// The Zeigarnik effect means unfinished tasks keep firing reminder
// signals — naming them here, even without acting, is what silences
// the loop.
//
// Copy rule: additive framing only ("You finished X"), never
// subtractive ("X still open"). Open loops are listed but read-only —
// transactional work belongs on the Tasks page.
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { Moon, ArrowLeft } from 'lucide-svelte';
import { page } from '$lib/stores/page.svelte.js';
import { hasTauriRuntime } from '$lib/utils/runtime.js';
interface TaskRow {
id: string;
text: string;
bucket: string;
done: boolean;
doneAt: string | null;
createdAt: string;
}
let completedToday = $state<TaskRow[]>([]);
let openLoops = $state<TaskRow[]>([]);
let loading = $state(true);
function localDateKey(iso: string | null | undefined): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function todayKey(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
async function load() {
if (!hasTauriRuntime()) {
loading = false;
return;
}
try {
const all = await invoke<TaskRow[]>('list_tasks_cmd');
const today = todayKey();
completedToday = all.filter((t) => t.done && localDateKey(t.doneAt) === today);
openLoops = all.filter((t) => !t.done);
} catch {
completedToday = [];
openLoops = [];
} finally {
loading = false;
}
}
function close() {
page.current = 'dictation';
}
onMount(load);
</script>
<div class="flex flex-col h-full bg-bg overflow-y-auto">
<div class="flex items-center gap-3 px-7 pt-6 pb-3">
<button
type="button"
class="p-1.5 rounded-lg text-text-tertiary hover:text-text hover:bg-hover"
onclick={close}
aria-label="Back to dictation"
>
<ArrowLeft size={16} aria-hidden="true" />
</button>
<Moon size={18} class="text-text-secondary" aria-hidden="true" />
<h1 class="font-display text-[26px] italic text-text">Wind down</h1>
</div>
<div class="px-7 pb-8 max-w-[640px]">
{#if loading}
<p class="text-[12px] text-text-tertiary py-6">Looking at today…</p>
{:else}
<!-- Additive framing: lead with what the user did, never with what they didn't. -->
<section class="mb-8">
<p class="font-display text-[18px] italic text-text mb-2">
{#if completedToday.length === 0}
A quiet day.
{:else if completedToday.length === 1}
You finished one thing today.
{:else}
You finished {completedToday.length} today.
{/if}
</p>
{#if completedToday.length > 0}
<ul class="mt-3 flex flex-col gap-1.5">
{#each completedToday as task (task.id)}
<li class="text-[13px] text-text-secondary leading-relaxed">
<span class="text-success mr-2" aria-hidden="true"></span>{task.text}
</li>
{/each}
</ul>
{/if}
</section>
<!-- Read-only reflection. The Tasks page is where things get done. -->
<section class="mb-8">
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Open loops</h2>
{#if openLoops.length === 0}
<p class="text-[12px] text-text-secondary">Nothing in the list.</p>
{:else}
<p class="text-[11px] text-text-tertiary mb-3">
These are still here. Naming them silences the loop — you don't have to act now.
</p>
<ul class="flex flex-col gap-1">
{#each openLoops.slice(0, 12) as task (task.id)}
<li class="text-[12px] text-text-secondary truncate">
{task.text}
</li>
{/each}
{#if openLoops.length > 12}
<li class="text-[11px] text-text-tertiary pt-1">
…and {openLoops.length - 12} more.
</li>
{/if}
</ul>
{/if}
</section>
<!-- Physical reset + intentional cue. Evidence: Newport shutdown
template, ~40% rumination reduction in psychological-detachment
studies. -->
<section class="mb-8">
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Before you close</h2>
<ol class="flex flex-col gap-2 text-[13px] text-text-secondary">
<li>
<span class="font-medium text-text">Take a breath.</span>
Stand up. Stretch for ten seconds.
</li>
<li>
<span class="font-medium text-text">Pick a closing line.</span>
Say it out loud when you're ready — something like "I'm done for today."
</li>
<li>
<span class="font-medium text-text">Let it rest.</span>
Tomorrow's list will be here. Work done for today.
</li>
</ol>
</section>
<div class="pt-2">
<button
type="button"
class="px-4 py-2 rounded-lg bg-accent text-white text-[12px] font-medium hover:bg-accent-hover"
onclick={close}
>
Close
</button>
</div>
{/if}
</div>
</div>

View File

@@ -1,14 +1,17 @@
<script lang="ts">
import { tick } from "svelte";
import type { TaskBucket, TaskList } from "$lib/types/app";
import type { EnergyLevel, TaskBucket, TaskEntry, TaskList } from "$lib/types/app";
import { invoke } from "@tauri-apps/api/core";
import {
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
setTaskEnergy,
taskLists, addTaskList, renameTaskList, deleteTaskList,
settings, saveSettings,
} from "$lib/stores/page.svelte.js";
import WipTaskList from '$lib/components/WipTaskList.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight } from 'lucide-svelte';
import EnergyChip from '$lib/components/EnergyChip.svelte';
import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Zap } from 'lucide-svelte';
import Card from "$lib/components/Card.svelte";
import { formatTimestamp } from "$lib/utils/time.js";
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
@@ -47,6 +50,17 @@
return EFFORT_LABELS[effort as keyof typeof EFFORT_LABELS] || effort;
}
// Phase 3 energy sort: when the user has opted in and declared a
// current energy, tasks matching that energy sort to the top. Tasks
// with no energy tag are treated as Medium-equivalent, per the brief's
// framing that unset is the normal case. Nothing is ever hidden —
// "reserves" in the spec means de-prioritise, not filter.
function energyMatchRank(task: TaskEntry, currentEnergy: EnergyLevel | null): number {
if (!currentEnergy) return 0;
const effective = task.energy ?? "medium";
return effective === currentEnergy ? 0 : 1;
}
let filteredTasks = $derived.by(() => {
let list = tasks.filter((t) => !t.done);
if (activeBucket !== "all") {
@@ -68,9 +82,84 @@
} else if (sortMode === "deep-first") {
list.sort((a, b) => effortRank(b.effort) - effortRank(a.effort));
}
// Match-my-energy sort runs last (stable) so it reorders the result
// of the effort-based sort rather than replacing it.
if (settings.matchMyEnergy && settings.currentEnergy) {
const energy = settings.currentEnergy;
list.sort((a, b) => energyMatchRank(a, energy) - energyMatchRank(b, energy));
}
return list;
});
function cycleCurrentEnergy(next: EnergyLevel | null) {
settings.currentEnergy = next;
saveSettings();
}
function toggleMatchMyEnergy() {
settings.matchMyEnergy = !settings.matchMyEnergy;
saveSettings();
}
function energyLabel(level: EnergyLevel | null): string {
switch (level) {
case "high": return "High";
case "medium": return "Medium";
case "brain_dead": return "Brain-Dead";
default: return "Not set";
}
}
// ARIA radiogroup keyboard handling for the energy segmented control.
// Full W3C APG Radio Group pattern: arrow keys cycle, Home / End jump
// to ends, focus and selection move together. The options array lives
// here so the keyboard handler and the render loop share one source
// of truth — desynchronising them is the usual way these patterns
// rot over time.
type EnergyOption = { value: EnergyLevel | null; label: string };
const energyOptions: EnergyOption[] = [
{ value: null, label: "—" },
{ value: "high", label: "High" },
{ value: "medium", label: "Med" },
{ value: "brain_dead", label: "Low" },
];
let energyRadioGroupEl = $state<HTMLDivElement | null>(null);
function selectEnergyByIndex(idx: number) {
const clamped = Math.max(0, Math.min(energyOptions.length - 1, idx));
const opt = energyOptions[clamped];
cycleCurrentEnergy(opt.value);
// Move focus to the newly-checked button so keyboard nav tracks
// selection, per the ARIA radio pattern.
const btn = energyRadioGroupEl?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[clamped];
btn?.focus();
}
function energyRadioKeydown(e: KeyboardEvent) {
const current = energyOptions.findIndex((o) => o.value === settings.currentEnergy);
const here = current < 0 ? 0 : current;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
selectEnergyByIndex((here + 1) % energyOptions.length);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
selectEnergyByIndex((here - 1 + energyOptions.length) % energyOptions.length);
break;
case "Home":
e.preventDefault();
selectEnergyByIndex(0);
break;
case "End":
e.preventDefault();
selectEnergyByIndex(energyOptions.length - 1);
break;
}
}
let completedTasks = $derived.by(() => {
let list = tasks.filter((t) => t.done);
if (activeBucket !== "all") {
@@ -199,6 +288,51 @@
<p class="text-[11px] text-text-tertiary mt-1">Add tasks manually. Automatic extraction from your transcripts is coming.</p>
</div>
<div class="flex-1"></div>
<!-- Phase 3 match-my-energy control. Three-state energy selector +
toggle for the sort. Sits in the header so it is always visible
from any bucket / list context. -->
<div class="flex items-center gap-2 mr-2">
<span class="text-[10px] text-text-tertiary hidden sm:inline" aria-hidden="true">I feel</span>
<div
bind:this={energyRadioGroupEl}
class="flex items-center gap-0.5 bg-bg-input border border-border-subtle rounded-lg p-0.5"
role="radiogroup"
aria-label="My current energy"
tabindex="-1"
onkeydown={energyRadioKeydown}
>
{#each energyOptions as opt}
{@const checked = settings.currentEnergy === opt.value}
<button
class="text-[10px] px-2 py-0.5 rounded-md
{checked
? 'bg-accent/15 text-accent'
: 'text-text-tertiary hover:text-text-secondary'}"
role="radio"
aria-checked={checked}
aria-label={opt.value ? `Set current energy to ${energyLabel(opt.value)}` : 'Clear current energy'}
tabindex={checked ? 0 : -1}
onclick={() => cycleCurrentEnergy(opt.value)}
>{opt.label}</button>
{/each}
</div>
<button
class="flex items-center gap-1 btn-md rounded-lg text-[10px]
{settings.matchMyEnergy
? 'bg-accent/15 text-accent border border-accent/30'
: 'text-text-tertiary hover:bg-hover hover:text-text border border-transparent'}"
style="transition-duration: var(--duration-ui)"
onclick={toggleMatchMyEnergy}
aria-pressed={settings.matchMyEnergy}
aria-label={settings.matchMyEnergy ? 'Match my energy is on — click to turn off' : 'Match my energy is off — click to turn on'}
title="Sort matching tasks to the top (unset counts as Medium)"
>
<Zap size={12} aria-hidden="true" />
Match my energy
</button>
</div>
<button
class="flex items-center gap-1.5 btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text"
style="transition-duration: var(--duration-ui)"
@@ -451,6 +585,13 @@
onclick={() => setEffort(task.id, e)}
>{effortLabel(e)}</button>
{/each}
<span class="text-border-subtle">&middot;</span>
<!-- Energy chip (Phase 3) -->
<EnergyChip
energy={task.energy}
onSelect={(next) => setTaskEnergy(task.id, next)}
size="md"
/>
<!-- Timestamp -->
{#if task.createdAt}
<span class="text-[10px] text-text-tertiary ml-auto">{formatTimestamp(task.createdAt)}</span>

View File

@@ -0,0 +1,230 @@
// Focus timer store. Single active timer at a time — the "just-start"
// 2/5/10/15-minute countdown paired with micro-steps. Exposes:
// - focusTimer.active: whether a timer is currently running
// - focusTimer.progress: 0..1 fraction of elapsed time
// - focusTimer.remainingMs: milliseconds until completion
// - focusTimer.label / focusTimer.taskId: what this timer is for
// - start(seconds, opts) / cancel() / extend(seconds)
//
// Survives window close + reopen via localStorage, because a timer
// that loses its clock when the user alt-tabs is a timer that nobody
// trusts. On rehydrate after expiry, fires completion then clears —
// so closing the window mid-timer still gets you the "done" signal
// on next launch.
const STORAGE_KEY = "kon.focusTimer.v1";
const TICK_INTERVAL_MS = 250;
export type FocusTimerPersisted = {
startedAt: number;
durationMs: number;
taskId: string | null;
label: string | null;
};
function readPersisted(): FocusTimerPersisted | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (
typeof parsed?.startedAt !== "number" ||
typeof parsed?.durationMs !== "number"
) return null;
return {
startedAt: parsed.startedAt,
durationMs: parsed.durationMs,
taskId: parsed.taskId ?? null,
label: parsed.label ?? null,
};
} catch {
return null;
}
}
function writePersisted(state: FocusTimerPersisted | null): void {
if (typeof window === "undefined") return;
try {
if (state === null) window.localStorage.removeItem(STORAGE_KEY);
else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* storage may be disabled; non-fatal */ }
}
function createFocusTimerStore() {
let startedAt = $state<number | null>(null);
let durationMs = $state<number>(0);
let taskId = $state<string | null>(null);
let label = $state<string | null>(null);
let now = $state<number>(Date.now());
let completionFlashUntil = $state<number>(0);
let interval: ReturnType<typeof setInterval> | null = null;
const active = $derived(startedAt !== null);
const elapsedMs = $derived(startedAt === null ? 0 : Math.max(0, now - startedAt));
const remainingMs = $derived(Math.max(0, durationMs - elapsedMs));
const progress = $derived(durationMs === 0 ? 0 : Math.min(1, elapsedMs / durationMs));
const finished = $derived(active && remainingMs === 0);
const showingCompletionFlash = $derived(now < completionFlashUntil);
// Internal completion + flash bookkeeping. We track whether we have
// already fired the completion chime for the current timer so a
// second tick does not re-fire it. Reset whenever a new timer starts.
let completionFired = false;
function tick() {
now = Date.now();
// Fire completion once, the first tick after we cross remaining=0.
if (startedAt !== null && !completionFired && now - startedAt >= durationMs) {
completionFired = true;
completionFlashUntil = now + 3000;
fireCompletion();
}
// After the 3 s flash window, clear everything and stop ticking.
if (completionFlashUntil > 0 && now >= completionFlashUntil) {
clear();
}
}
function startTick() {
if (interval !== null) return;
interval = setInterval(tick, TICK_INTERVAL_MS);
}
function stopTick() {
if (interval !== null) {
clearInterval(interval);
interval = null;
}
}
function clear() {
startedAt = null;
durationMs = 0;
taskId = null;
label = null;
completionFlashUntil = 0;
completionFired = false;
writePersisted(null);
stopTick();
}
function start(seconds: number, opts?: { taskId?: string | null; label?: string | null }): void {
if (!Number.isFinite(seconds) || seconds <= 0) return;
now = Date.now();
startedAt = now;
durationMs = Math.floor(seconds * 1000);
taskId = opts?.taskId ?? null;
label = opts?.label ?? null;
completionFlashUntil = 0;
completionFired = false;
writePersisted({ startedAt, durationMs, taskId, label });
startTick();
}
function cancel(): void {
clear();
}
function extend(seconds: number): void {
if (startedAt === null) return;
if (!Number.isFinite(seconds) || seconds <= 0) return;
durationMs += Math.floor(seconds * 1000);
writePersisted({ startedAt, durationMs, taskId, label });
}
function dismissCompletionFlash(): void {
completionFlashUntil = 0;
clear();
}
function fireCompletion(): void {
if (typeof window === "undefined") return;
// Gentle chime — WebAudio so we do not ship a bundled asset.
// A 440 Hz fall into 330 Hz over 220 ms, low volume, no sustain.
try {
type AudioCtx = typeof AudioContext;
const win = window as unknown as { AudioContext?: AudioCtx; webkitAudioContext?: AudioCtx };
const Ctx = win.AudioContext ?? win.webkitAudioContext;
if (!Ctx) return;
const ctx = new Ctx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(440, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(330, ctx.currentTime + 0.22);
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.15, ctx.currentTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.28);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.3);
osc.onended = () => ctx.close().catch(() => {});
} catch { /* audio is a nicety; never fatal */ }
window.dispatchEvent(new CustomEvent("kon:focus-timer-complete", {
detail: { taskId, label },
}));
}
// Rehydrate on first touch. If the persisted timer has already
// expired, fire completion then clear so the user still gets the
// "done" signal they missed while the window was closed.
function rehydrate(): void {
const persisted = readPersisted();
if (!persisted) return;
const age = Date.now() - persisted.startedAt;
if (age >= persisted.durationMs) {
// Already expired while the window was closed. Fire a completion
// event so downstream listeners (nudges, UI flourishes) can react.
taskId = persisted.taskId;
label = persisted.label;
durationMs = persisted.durationMs;
startedAt = persisted.startedAt;
now = persisted.startedAt + persisted.durationMs;
// Flash briefly so the user knows it happened.
completionFlashUntil = Date.now() + 3000;
completionFired = true;
fireCompletion();
startTick();
return;
}
startedAt = persisted.startedAt;
durationMs = persisted.durationMs;
taskId = persisted.taskId;
label = persisted.label;
now = Date.now();
startTick();
}
// Exposed as frozen object. Getters so derivations stay reactive.
return {
get active() { return active; },
get progress() { return progress; },
get elapsedMs() { return elapsedMs; },
get remainingMs() { return remainingMs; },
get durationMs() { return durationMs; },
get taskId() { return taskId; },
get label() { return label; },
get showingCompletionFlash() { return showingCompletionFlash; },
start,
cancel,
extend,
rehydrate,
dismissCompletionFlash,
};
}
export const focusTimer = createFocusTimerStore();
// Preset durations surfaced in the UI. 2 / 5 / 10 / 15 minutes match
// the brief's guidance for the "just-start" timer and cover common
// Pomodoro / shorter-focus preferences.
export const FOCUS_TIMER_PRESETS_SECONDS: ReadonlyArray<{ label: string; seconds: number }> = [
{ label: "2 min", seconds: 120 },
{ label: "5 min", seconds: 300 },
{ label: "10 min", seconds: 600 },
{ label: "15 min", seconds: 900 },
];

View File

@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import type {
EnergyLevel,
PageState,
Profile,
Segment,
@@ -68,6 +69,15 @@ const defaults: SettingsState = {
globalHotkey: "Ctrl+Shift+R",
sidebarCollapsed: false,
microphoneDevice: "",
currentEnergy: null,
matchMyEnergy: false,
ttsVoice: null,
ttsRate: 1.0,
ritualsMorning: false,
ritualsMorningTime: "08:00",
ritualsEvening: false,
launchAtLogin: false,
ritualsPromptSeen: false,
};
function canUseStorage(): boolean {
@@ -314,6 +324,7 @@ function mapTaskRow(row: TaskDto): TaskEntry {
createdAt: row.createdAt ?? new Date().toISOString(),
sourceTranscriptId: row.sourceTranscriptId ?? null,
parentTaskId: row.parentTaskId ?? null,
energy: row.energy ?? null,
};
}
@@ -352,6 +363,7 @@ export async function addTask(task: TaskDraft) {
sourceTranscriptId: task.sourceTranscriptId || null,
listId: task.listId ?? null,
effort: task.effort ?? null,
energy: task.energy ?? null,
},
});
tasks.unshift(mapTaskRow(row));
@@ -396,6 +408,29 @@ export async function updateTask(id: string, updates: TaskUpdate) {
}
}
/**
* Phase 3: explicit tri-state energy setter. Lives outside `updateTask`
* because the backend uses a dedicated command for clearing — see
* `set_task_energy_cmd` in src-tauri/src/commands/tasks.rs. Pass `null`
* to clear the tag entirely; pass an `EnergyLevel` string to set it.
*/
export async function setTaskEnergy(id: string, energy: EnergyLevel | null) {
if (!hasTauriRuntime()) {
applyLocalTaskUpdate(id, { energy });
return;
}
try {
const row = await invoke<TaskDto>("set_task_energy_cmd", { id, energy });
const idx = tasks.findIndex((task) => task.id === id);
if (idx >= 0) {
tasks[idx] = mapTaskRow(row);
broadcastTasks();
}
} catch (err) {
toasts.error("Couldn't change energy", errorMessage(err));
}
}
export async function deleteTask(id: string) {
if (!hasTauriRuntime()) return;

View File

@@ -0,0 +1,10 @@
// Phase 4 Read Page Aloud: tracks which SpeakerButton instance is
// currently driving TTS. Only one speaker is active at a time — when
// a second button starts speech, the first reverts its icon via the
// `$derived` subscription in SpeakerButton.
export const activeSpeaker = $state<{ id: string | null }>({ id: null });
export function setActiveSpeaker(id: string | null): void {
activeSpeaker.id = id;
}

View File

@@ -56,6 +56,54 @@ export interface SettingsState {
globalHotkey: string;
sidebarCollapsed: boolean;
microphoneDevice: string;
/**
* Phase 3 match-my-energy: the user's self-reported current energy
* level. `null` means "not stated" — the sort falls back to created_at
* order. Persists across sessions because energy tracks the person,
* not the work.
*/
currentEnergy: EnergyLevel | null;
/**
* Phase 3 match-my-energy: when true, the Tasks page sorts tasks
* matching `currentEnergy` to the top (with unset tasks treated as
* Medium). Off by default so the list reads chronologically until
* the user explicitly opts in.
*/
matchMyEnergy: boolean;
/**
* Phase 4 Read Page Aloud: OS-native TTS. `voice` is the platform's
* voice id (e.g. macOS `Alex`), or `null` for system default.
* `rate` is 0.5..2.0 where 1.0 is the synth's normal speed.
*/
ttsVoice: string | null;
ttsRate: number;
/**
* Phase 5 rituals. All off by default — opt-in per Jake's standing
* rule that rituals must never feel parental. Discovery happens
* through the first-run prompt, not default-on behaviour.
*/
ritualsMorning: boolean;
/**
* HH:MM (24h, local time) — earliest clock time after which the
* morning triage modal is allowed to appear on a given day. Default
* 08:00 because ADHD sleep inertia intensifies the first ~45 minutes
* after waking (Aspire Therapy, CHADD); 06:00 is too early to ask
* for decisions.
*/
ritualsMorningTime: string;
ritualsEvening: boolean;
/**
* Phase 5: register Corbie as a login-time autostart entry. The
* actual OS-level state lives in `tauri-plugin-autostart`; this
* flag mirrors it so the Settings UI can render without a round-trip
* on every mount.
*/
launchAtLogin: boolean;
/**
* First-run has surfaced the ritual prompts at least once. Keeps the
* prompt from re-appearing on every update-triggered reboot.
*/
ritualsPromptSeen: boolean;
}
export interface Profile {
@@ -150,6 +198,14 @@ export interface TranscriptMetaPatch {
segments?: Segment[];
}
/**
* Phase 3: energy level tag on a task. `null` means unset — the sort
* treats unset as Medium-equivalent. Three tagged values match the
* brief's "High / Medium / Brain-Dead" language; `brain_dead` is the
* stored form because SQL enums don't love hyphens.
*/
export type EnergyLevel = "high" | "medium" | "brain_dead";
export interface TaskDto {
id: string;
text: string;
@@ -162,6 +218,7 @@ export interface TaskDto {
createdAt: string;
sourceTranscriptId: string | null;
parentTaskId: string | null;
energy: EnergyLevel | null;
}
export interface TaskEntry {
@@ -176,6 +233,7 @@ export interface TaskEntry {
createdAt: string;
sourceTranscriptId: string | null;
parentTaskId: string | null;
energy: EnergyLevel | null;
}
export interface TaskDraft {
@@ -184,6 +242,7 @@ export interface TaskDraft {
listId?: string | null;
effort?: string | null;
sourceTranscriptId?: string | null;
energy?: EnergyLevel | null;
}
export interface TaskUpdate {

View File

@@ -8,6 +8,8 @@
import Titlebar from "$lib/components/Titlebar.svelte";
import ToastViewport from "$lib/components/ToastViewport.svelte";
import ResizeHandles from "$lib/components/ResizeHandles.svelte";
import FocusTimer from "$lib/components/FocusTimer.svelte";
import MorningTriageModal from "$lib/components/MorningTriageModal.svelte";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
import { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
@@ -204,6 +206,17 @@
}
}
// Phase 5: tray menu routes "Evening wind-down" here so the page
// opens on whichever window the user clicks from. Unlisten on
// destroy like every other subscription in this file.
let unlistenWindDown = null;
async function setupWindDownListener() {
if (!tauriRuntimeAvailable) return;
unlistenWindDown = await listen("kon:open-wind-down", () => {
page.current = "shutdown";
});
}
// Cross-window preference sync: apply updates broadcast by any other
// window (float, viewer) while skipping our own echoes.
let unlistenPrefs = null;
@@ -261,6 +274,9 @@
// Cross-window preference sync (no-op outside Tauri).
setupPreferencesSync();
// Phase 5: subscribe to tray wind-down event.
setupWindDownListener();
// Diagnostics: capture every uncaught frontend error to error_log.
installGlobalErrorCapture();
@@ -361,6 +377,9 @@
if (unlistenPrefs) {
unlistenPrefs();
}
if (unlistenWindDown) {
unlistenWindDown();
}
});
</script>
@@ -396,6 +415,18 @@
in the bottom-right of the viewport. (Day 3 of the upgrade plan) -->
<ToastViewport />
<!-- Global focus-timer overlay. Renders nothing until a `kon:start-timer`
event fires; then pins a shrinking colour ring to the top-right.
Phase 1 of the 2026-04-23 feature-complete roadmap — closes the
visual-time-representation gap from docs/brief/feature-set.md and
wires the dangling emit in MicroSteps.svelte. -->
<FocusTimer />
<!-- Phase 5: morning triage modal. Self-gated — no-op unless the user
has enabled `ritualsMorning` and the local clock is past their set
trigger time. Mounted here so it can appear over any page. -->
<MorningTriageModal />
<!-- Invisible resize margins for frameless (macOS/Windows). On Linux we
use native decorations, so ResizeHandles would compete with the
compositor's own resize and is suppressed. -->

View File

@@ -6,6 +6,7 @@
import HistoryPage from "$lib/pages/HistoryPage.svelte";
import SettingsPage from "$lib/pages/SettingsPage.svelte";
import FirstRunPage from "$lib/pages/FirstRunPage.svelte";
import ShutdownRitualPage from "$lib/pages/ShutdownRitualPage.svelte";
// Redirect legacy "profiles" page to settings
$effect(() => {
@@ -26,5 +27,7 @@
<HistoryPage />
{:else if page.current === "settings"}
<SettingsPage />
{:else if page.current === "shutdown"}
<ShutdownRitualPage />
{/if}
</main>

View File

@@ -12,6 +12,7 @@
PREFERENCES_CHANGED_EVENT,
} from "$lib/stores/preferences.svelte.js";
import Titlebar from "$lib/components/Titlebar.svelte";
import FocusTimer from "$lib/components/FocusTimer.svelte";
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
let { children } = $props();
@@ -90,3 +91,9 @@
{@render children()}
</div>
</div>
<!-- Focus timer also visible in the always-on-top float window so a
running countdown stays with the Now list. The component is a
global overlay (position: fixed) so it pins to the top-right of
this window independent of the Tasks content below. -->
<FocusTimer />

View File

@@ -3,6 +3,7 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import type { TranscriptEntry, ViewerSegment } from "$lib/types/app";
import SpeakerButton from "$lib/components/SpeakerButton.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { errorMessage } from "$lib/utils/errors.js";
@@ -374,13 +375,18 @@
{#if item}
<!-- Item info -->
<div class="px-5 pt-3 pb-2">
<p class="text-[13px] text-text font-medium">
{item.title || "Transcript"}
</p>
<p class="text-[10px] text-text-tertiary mt-0.5">
{item.date}{#if item.duration} · {formatDuration(item.duration)}{/if} · {item.source}
</p>
<div class="px-5 pt-3 pb-2 flex items-start gap-2">
<div class="flex-1 min-w-0">
<p class="text-[13px] text-text font-medium">
{item.title || "Transcript"}
</p>
<p class="text-[10px] text-text-tertiary mt-0.5">
{item.date}{#if item.duration} · {formatDuration(item.duration)}{/if} · {item.source}
</p>
</div>
{#if item.text?.trim()}
<SpeakerButton text={item.text} label="Read transcript aloud" size={14} />
{/if}
</div>
<!-- Media controls -->