The "better, but not now" ledger. Every time we make a deliberate scope cut — a change that would make Hark a better product but that we chose to defer — it gets written here with enough context to finish it later, so nothing quietly rots.
This is the product-improvement backlog. It is distinct from STATUS.md "Open threads"
(technical watch-items / latent risks) and from ADR "Open questions" (decision-specific).
When an item here is done, move it out (cite the commit) — don't let this list lie.
Format per item: what it is · why we deferred · where to pick up (files / ADR refs). Add new items under the right area; create a new area if none fits.
Last updated: 2026-06-04
- Notarization + stapling. The final step to a Gatekeeper-clean, double-clickable app
that runs on other people's Macs without the "unidentified developer / damaged" block.
- Deferred because: requires a paid Apple Developer Program membership ($99/yr) and
a Developer ID Application certificate. The dev flow uses a free "Apple Development"
cert (
engine/scripts/sign-dev-bundle.sh), which cannot notarize. - Pick up:
@electron/notarizeafterSign hook +notarytool+stapler. We build the app notarization-ready now (hardened runtime + entitlements), so this is the last mile once a Developer ID is available. Packaging ADR (TBD) will record the signing chain.
- Deferred because: requires a paid Apple Developer Program membership ($99/yr) and
a Developer ID Application certificate. The dev flow uses a free "Apple Development"
cert (
- Configurable vault location. The vault path is fixed at
~/Documents/vault/hark(mainVAULT_DIR+ the engine'sVaultWriter). The onboarding Setup screen (ADR-0023) and Settings show a folder picker, but it's disabled until this lands.- Pick up: a vault-path pref + a folder picker (
dialog.showOpenDialog), plumbed to the engine soVaultWriterwrites there. Makes onboarding screen-3 fully match the design.
- Pick up: a vault-path pref + a folder picker (
- Anthropic API key storage (Keychain). The onboarding Setup screen + the design show an
optional API-key field — "stored in macOS Keychain, we never see your key." Disabled until
Phase 6 (the key has nothing to authenticate against yet).
- Pick up: a Keychain-backed secure store (main process) + the key field; gates the cloud features (summary/translation/Q&A). Part of Phase 6.
- App icon. No
ui/build/icon.icnsexists; electron-builder warns and falls back to the default Electron icon. Fine for the unsigned dry-run, not for a release.- Pick up: drop a 1024px
icon.icns(or.png, electron-builder converts) atui/build/.
- Pick up: drop a 1024px
The product supports multiple model providers — cloud AND local — not just Claude (user directive, 2026-06-02). Local models are a privacy win: summaries / Q&A / translation with zero data leaving the machine, making cloud one option, not the only path. The UI is being built provider-agnostic now; the integration lands later.
- Provider abstraction + foundation — SHIPPED (Slice 1). Pluggable LLM layer in Electron
main (ADR-0029): Anthropic-native + OpenAI-compatible (covers OpenAI / Gemini / OpenRouter
cloud AND local Ollama / LM Studio / llama.cpp on
localhost), rawfetch(no SDK). Cloud key in macOS Keychain viasafeStorage(ADR-0030); local needs none. Settings → Models pane (provider / model / baseUrl / key / Test connection);modelConfigured()gate live. Engine stays network-free; CSP unchanged. Privacy-audited PASS. - Meeting summary — SHIPPED (Slice 2). "Summarize" on the saved card → renderer builds the
transcript →
llm.summarizein main → (redact if cloud) → Anthropic/OpenAI-compatible completion → summary panel + receipt → "Save to note" writes## Summaryto the vault via the engine (summary.write→VaultWriter.appendSummary+ git-commit). Cloud-call log + Settings Cloud-activity list shipped. Local model ⇒ full transcript, zero egress. ADR-0031. Audited PASS.- Redaction v1 gaps (ADR-0031 §3, deferred to NER): regex catches email/url/money/phone/ long-numbers + known roster names; it does NOT catch zip-style 5–6 digit numbers, word-form currency ("50 dollars"), or arbitrary free-text names. The receipt is honest (claims a count, not "all PII removed"). Pick up: a local NER pass for names + a user-facing redaction toggle.
- Non-streaming v1: the summary returns whole (≤60s), no token streaming. Pick up: SSE streaming over IPC for a live-typing summary (design wants it; deferred for simplicity).
- This-meeting Q&A — SHIPPED (Slice 3). The Ask panel is wired: a question + the current
meeting transcript →
llm.askin main → (redact question AND transcript if cloud) → grounded answer ("answer only from this transcript; say you don't know rather than invent"). Same egress governance + cloud-log (action:'qa') as summary; local ⇒ zero egress. ADR-0031.- Deferred: rich
[1][2]citations / source cards are left empty (NOT faked) — they need retrieval / structured output, which arrives with the vault-RAG slice. Answer streaming also deferred (non-streaming v1, like summary).
- Deferred: rich
- Vault-wide / 2nd-brain Q&A — RAG (Slice 4) — DESIGN-LOCKED (ADR-0032 + ADR-0033), build
pending. PLUGGABLE backend (ADR-0033): the user chooses at onboarding — built-in Hark
(engine embedder + index per ADR-0032, out-of-box, default) or external (their own local
retrieval service, ideally a loopback MCP server, reusable by other MCP clients). Both feed
the same redact→LLM→citations path in main; the external endpoint must be loopback-only. Whoever
picks external skips Hark's CoreML model entirely.
Cross-meeting questions over the whole vault. Cloud model NEVER sees the vault: local embed
→ local vector search (top-K) → only the redacted top-K chunks + question go to the model →
answer + citations. Local model ⇒ zero egress. Decisions (ADR-0032): a curated set of LOCAL
CoreML embedders in the engine (ANE, model-cache pattern), defaulting to multilingual
multilingual-e5-small(384-dim) for VI/TH/EN notes + an Englishbge-small-enoption, user-choosable in Settings, local-only (never a cloud embed endpoint) and re-index on change; brute-force in-memory cosine over a flat file (NOT sqlite-vec for v1 — <80 ms at 50k chunks, no native dep; sqlite-vec is the100k scale-up); index in app-data (not the vault); engine FSEvents watcher (30 s + content-hash); heading-aware chunking with
notePath/headingPath/charRangefor citations; newrag.retrieve+rag.index_statuswire frames; renderer scope toggle (this-meeting | vault).- Obsidian 2nd-brain (HYBRID): the user's external tool syncs Obsidian → the vault folder
(Markdown); Hark reads it and owns the local index (writes only app-data). Obsidian can
point at the vault directly (
[[wikilinks]]parsing is a separate backlog item). - Build plan (ADR-0033 backend split) — ALL FOUR DONE (2026-06-03): (1) ✅
RetrievalBackendselector + onboarding/Settings choice (default built-in); (2) ✅ built-in backend (4a+4b+4c below); (3) ✅ external backend — loopback MCP/HTTP client in main + connection check + loopback guard; (4) ✅ shared answer path — backend → redact →llm.ask→ answer + citations + Ask-panel scope toggle. Built-in vault RAG is end-to-end usable today; external is selectable + configurable in Settings. Remaining before SHIPPING built-in: publish the FP16.mlpackage(deploy TODO under 4a status). - 4a status — DONE + on-device validated (2026-06-03). Engine
TextEmbedder(CoreML, ANE)- loader (+
HARK_EMBEDDER_LOCAL_DIRdev override) + curated registry +swift-transformers1.3.3. Themultilingual-e5-smallCoreML conversion (scripts/convert-embedder-coreml.py) was run on-device; the gated cross-lingual test (EN↔TH/VI closer than unrelated) passes on ANE. 123 engine tests green; privacy-audited PASS (network use = the routed model download only).
- Deploy / model hosting — DONE (2026-06-03). The base conversion is already fp16
(~224 MB);
scripts/quantize-embedder-int8.pyint8-quantizes it to ~113 MB (fp16↔int8 cosine 0.99986, both gated on-device tests green). Published totuanda2912/hark-multilingual-e5-small-coreml(public, MIT) viascripts/publish-embedder-hf.sh;EmbedderModels.swiftpins that repo + revision0a386d4…. Production first-run path validated end-to-end (no env override): a clean run downloaded from HF (24 s), ANE-compiled (6 s), and both the cross-lingual + full retrieve tests passed; the second run hit the cache (no re-download). Built-in vault RAG is now shippable. Considered + rejected reusingtamikisg/multilingual-e5-small-coreml— its output is a pre-pooled[1,384]with a fixed 256 seq + unstated pooling, incompatible with ourlast_hidden_state+ flexible-512 + masked-mean-pool loader. Optional later: cache the compiled.mlmodelcunderHarkPathsto skip recompiles.
- loader (+
- 4b status — DONE + on-device validated (2026-06-03). Heading-aware
RagChunker,RagIndex(brute-force cosine overvectors.bin+meta.jsonl+manifest.json),RagIndexer(cold build + FSEvents watcher + incremental + retrieve), and therag.retrieve/rag.results/rag.index_statuswire frames. Offset-only (decision 2026-06-03):meta.jsonlholds pointers only (no raw note text); retrieve reads the snippet live from the vault at the stored offsets and skips any file deleted/changed since index. 168 engine tests green incl. the real-embedder end-to-end + the FSEvents SIGSEGV fix (kFSEventStreamCreateFlagUseCFTypes). Privacy-audited PASS. - 4c status — DONE (2026-06-03). UI wiring: renderer
EngineService.retrieve()(the one request/reply exchange on the socket, id-correlatedrag.retrieve→rag.results, with timeout + socket-close cleanup) + aragIndexStatussignal; mainask()gains a vault scope that builds a numbered-Sources prompt and (cloud) redacts EACH chunk independently; Ask-panel this-meeting | vault scope toggle + index indicator; host orchestration (retrieve → llm.ask → numbered source cards from chunk metadata). The empty[1][2]citations Slice 3 left are now real source cards. Renderer makes no network call; engine never calls a model. Live WS smoke proves the round-trip on-device. ADR-0032/0033.- Cold-start ordering — FIXED in 4c. The RAG embedder used to load in
HarkdCommandstrictly AFTER WhisperKit + the diarizer (sequentialawaits), so on a cold start vault search wasRAG_UNAVAILABLEfor the minutes the large-v3 ANE compile takes — even though RAG is independent of capture. Now loaded in a detachedTasklaunched before the WhisperKit await, so the embedder + index come up concurrently with (and never wait behind) the speech model. The 4c smoke confirms it: vault retrieval returned 5 hits whilemeta.hellostill reportedmodel_loaded=(loading). - Jump-to-source (pick up): source cards + inline
[n]are display-only. Wire thecitation-chip/ source-cardselectto open the note atchar_start(Finder reveal, or an in-app note view) — the chunk already carriesnote_path+ offsets. Also: inline[n]renders as plain text in the answer prose; a later pass can split the prose and render real chips inline.
- Cold-start ordering — FIXED in 4c. The RAG embedder used to load in
- External backend — DONE (2026-06-03), ADR-0034. Pluggable retrieval is complete: a
RetrievalService(renderer) routes vault Ask to the built-in engine index OR a user-run LOCAL service perprefs.rag.backend. Main'ssrc/main/rag/is a hand-rolled (no SDK, rawfetch) loopback client with two transports — plain HTTP (POST {query,k,scope}→{chunks}) and minimal MCP-over-HTTP (initialize→tools/call, JSON or SSE). Loopback guard before every fetch +redirect:'error'(SSRF). Settings → "Vault search" picks backend + transport + endpoint + tool name + Test connection; onboarding has a built-in/external choice. 12-check transport smoke + privacy-audited PASS (7/7).- Deferred (external backend): (1) live index-status for external — the Ask-panel
indicator shows a static "Using your external backend" label (we don't poll the external
index state); a future
rag.index_status-equivalent over the bridge could surface building/ready. (2) MCP client is minimal — initialize + tools/call + tools/list only (no resources/prompts/notifications/OAuth, no mandatory multi-event SSE streaming); adopt@modelcontextprotocol/sdk(new ADR) if a real server outgrows it. (3) session reuse — the MCP client re-initializes per retrieve (fine at Q&A frequency; cache the session if it becomes hot). (4) external chunks carry no char offsets ⇒ no jump-to-source-by-offset.
- Deferred (external backend): (1) live index-status for external — the Ask-panel
indicator shows a static "Using your external backend" label (we don't poll the external
index state); a future
- Renderer/main RAG unit tests (pick up): the engine side is unit-tested (Swift) + the
external transports have a Node smoke (
/tmp, not committed), but the renderer (RetrievalService,EngineService.retrievecorrelation) + the mainrag/transports have no committed test runner. Stand up vitest (ornode --testover the compiled main) and lock the loopback guard +redirect:'error'+ correlation/timeout in (privacy-auditor LOW note).
- Obsidian 2nd-brain (HYBRID): the user's external tool syncs Obsidian → the vault folder
(Markdown); Hark reads it and owns the local index (writes only app-data). Obsidian can
point at the vault directly (
- Cloud-call log + PII redaction — SHIPPED (Slice 2). Every cloud call redacts PII first + logs metadata-only; surfaced in Settings → Privacy "Cloud activity". Local calls need neither. (NER name redaction + a redaction toggle remain deferred — see the summary entry.)
- Rename arbitrary PAST meetings.
speaker.rename(ADR-0020) MVP only renames the most-recently-saved meeting (the one the engine still holds in memory).- Deferred because: renaming an old meeting needs a meeting-browser UI + reloading or re-parsing the saved file's snapshot. MVP scope kept it to the live toast.
- Pick up: meeting browser + persist/reload
SavedMeetingSnapshot(or re-parse markdown). See ADR-0020 "Open questions". Manual markdown edit is the interim escape hatch.
- Speaker enrollment + cross-meeting recognition. Turn "Speaker N" into known people
automatically.
vault/.speakers/*.jsonvoiceprints + cosine matching (rule #5: stays local).- Deferred because: Phase 5.1; the rename MVP (ADR-0020) deliberately ships naming first.
- Pick up: ADR-0016 / ADR-0020 references; the
speaker.renameround-trip is the on-ramp.
- Diarization clustering-threshold tuning. Quick back-and-forth can mis-attribute speakers
(the skew we saw).
HARK_DIAR_THRESHOLDis the count-agnostic lever.- Deferred because: tuning against our only test clip (2-person) risks overfitting; can't validate N>2 safely without a real 3–4-person recording. Relabeling (ADR-0020) is the count-proof fix that made this non-urgent.
- Pick up: record a 3–4-person clip, sweep
HARK_DIAR_THRESHOLD, compare label splits. ADR-0017 flags theOfflineDiarizerManagerdefaults as needing real-meeting confirmation.
Speaker ?handling in the rename roster.applySpeakerNamesis roster-neutral; the unattributed "Speaker ?" bucket isn't surfaced for naming or stripped.- Pick up: decide whether to offer naming it or filter it (engine review LOW note, ADR-0020).
- Rename success-confirmation frame. Today the UI is optimistic and keys off bare
ack; only failures surface. Ameeting.updatedframe (or a correlationidon the ack) would give a positive "renamed ✓".- Pick up: ADR-0020 "Open questions". Low priority — MVP UX is acceptable.
- Source-level finalization strategy. Residual duplication / run-on fragments on
continuous, pause-less, repetitive narration aren't fully caught by post-hoc dedup +
supersession (ADR-0018) + region finalization (ADR-0019). The robust fix: finalize each
sentence once and dedup against finalized history at the source, not after the fact.
- Deferred because: largely an adversarial-input edge; real meeting audio (pauses + turn-taking) segments far more cleanly. Tracked (also a spawned task).
- Mid-sentence region boundaries. Region-based finalization (ADR-0019) occasionally cuts a sentence at a region edge — a readability nit, not a correctness bug.
sessionLanguagenil in the secondary drain path. A latent minor issue flagged during the content-loss fix; the secondary drain doesn't thread the session language hint.- Trailing
"."segment artifact. WhisperKit end-of-stream quirk; agreed to filter at the UI layer rather than mask at the engine boundary (STATUS open thread #1).
error.codecatalog reconciliation.docs/design/08-websocket-api-contract.mdlists a partial error-code enum; the engine emits more (MEETING_NOT_FOUND,ALREADY_RUNNING,ENGINE_WARMING_UP,UNSUPPORTED_TYPE,PROTOCOL_MISMATCH, …). Reconcile the doc.- Source: wire-symmetry review during ADR-0020.
- Stale
segment.finalspeakerexample. The contract doc still showssegment.finalcarrying a"Speaker 2"label "provisional during the meeting." Reality (post-ADR-0017/0020): livesegment.finalshipsspeaker: nil; labels exist only post-stop. Fix the doc example.
- Bookmark pins in the saved file.
bookmark.createworks on the wire, but the engine keeps no bookmark store, so pins don't land in the meeting markdown.- Pick up: persist bookmarks per session; render as anchors in the file (STATUS "Deferred polish").
- User-editable meeting title. Files are titled by auto-timestamp ("Meeting YYYY-MM-DD HH:MM"). Let the user set a real title (and reflect it in the frontmatter + filename policy).
- Post-Meeting Review screen — SHIPPED (slice B2). The user's verify-by-ear idea: play the
saved audio, click an utterance to hear that moment, assign/correct speaker names by ear.
Main reads the
.wav(vault-fenced,.wav-only) → renderer Blob URL → player + per-utterance seek + now-playing highlight + reusesspeaker.rename. Gated by Keep audio (no audio ⇒ the existing inline roster / Attendees tagging path). Polish left: a real waveform (we use a plain scrubber); audio-read symlink hardening (fs.realpathSync+ re-check before read — defense-in-depth; out of the current threat model since the engine is the sole writer of.audio/, per the privacy audit). - Audio compression (AAC/Opus). ADR-0028 ships uncompressed 16 kHz WAV (~1.9 MB/min). An
AVAudioConverterencode to.m4a/Opus would shrink a 1-hour meeting from ~115 MB to ~10 MB.- Deferred because: WAV reuses
HarkCore.WAVWriterand plays natively in Chromium with no transcode — simplest correct thing first. Pick up: ADR-0028 alternatives.
- Deferred because: WAV reuses
- Decouple audio buffering from diarizer-load. The whole-meeting buffer (
sessionAudio) is accumulated only whendiarizer != nil, sokeep_audiosilently no-ops if the diarizer failed to load. Pick up: gate theingestFramesbuffering onkeepAudio || diarizer != nil(ADR-0028 open question).
The shipped UI is a deliberate functional core of the full ~9-surface design
(vault/hark/docs/design/ui/artboards/). The rest is deferred until its backing
data/phase exists — building empty shells now would be dead UI. STATUS.md "remaining
Phase 4 surfaces" carries the live detail; summary:
- 3-column layout (attendees | transcript | Q&A). Blocked on diarization (partial now)
- Q&A (Phase 6). Single-column until the side columns have data.
- Q&A side panel (
QAPanel.jsx). Blocked on Phase 6 (Claude API). - Full speaker-management UI (
SpeakerTagging.jsxmodal + auto-recognition states). Naming is covered by the rename MVP (ADR-0020); enrollment/auto-match UI is Phase 5.1. - Settings → Privacy pane extras — redaction toggles, voiceprint folder, cloud-calls log. Slot in as their backing features land.
- Remaining design atoms —
SpeakerTag,Eyebrow,Toggle,CitationChip. Light theme— DONE (2026-06-03,e84cb21). Settings → Appearance toggle (System / Light / Dark, default System = follow macOS);ThemeServiceflips<html data-theme>, the existing dark+light tokens cascade. Pending the user: eyeball each screen in light against the design screenshots (02/08/10/12/16/21/24).- Wikilink
[[term]]parsing in the transcript — vault-linking, a later phase.
Translation is now a post-stop, on-demand action via the saved-card Translate panel. Live (translate-during-capture) translation was removed and deferred (ADR-0037): it was hard to test, timeout-prone on a small local model, and rendered messily. Two-tier (local = zero egress / cloud = HQ) like the rest.
- On-demand post-stop translation — SHIPPED (2026-06-04, structured;
aa5a2ea+ engine6d8a353). The saved-card Translate panel → target-language picker → the structured background job (TranslationJobService→ per-utterancellm.translateSegment→EngineService.writeTranslationLines→ engineappendTranslationStructured): each line is translated one at a time (progress banner, non-blocking), then the engine renders a## Transcript — <lang>section that is a byte-for-byte mirror of the original — same speaker labels / wall-clock / blockquote (ADR-0036). Local ⇒ zero egress; cloud redacts each line + ONE aggregated metadata-only cloud-log entry. Discloses egress before sending. Privacy-audited PASS.- Legacy single-blob path retained but DORMANT: the old whole-transcript
llm.translate→VaultWriter.appendTranslationblob (the 8192-token-truncation risk) is no longer used by the panel. A later cleanup may prune it (rendererLlmService.translate/EngineService.writeTranslation- engine
appendTranslation+ the wiretranslationblob field).
- engine
- Legacy single-blob path retained but DORMANT: the old whole-transcript
- Revive LIVE translation — DEFERRED (ADR-0037). Both the on-device
→ ENtoggle (§2, WhisperKittask:.translate) and the per-line LLM arbitrary-target picker (§3, the deletedLiveTranslationService→llm.translateSegment→segment.translationbilingual view) were removed. The engine'stask:.translateplumbing (capture.start.translation,liveTranslateToEnglish) and the per-line LLM path are left dormant; reviving §2 is mostly re-adding the UI toggle, and the removed §3 impl is ataa5a2ea^in git history.- Pick up (do it right this time): (1) ship an on-device MT model so §3 needs no cloud and no
per-line LLM latency — NLLB-200 CoreML (zero egress, 200 langs, but ~3.2 GB + seq2seq latency)
or Apple Translation (free / on-device / macOS-14.4, but a purely-SwiftUI API with no standalone
TranslationSession— incompatible with the headless engine + Electron UI as-is); (2) decouple live translation from the finalization watermark (ADR-0019) so the bilingual view doesn't churn — translate only stable, committed lines, debounced.
- Pick up (do it right this time): (1) ship an on-device MT model so §3 needs no cloud and no
per-line LLM latency — NLLB-200 CoreML (zero egress, 200 langs, but ~3.2 GB + seq2seq latency)
or Apple Translation (free / on-device / macOS-14.4, but a purely-SwiftUI API with no standalone
- Vietnamese / non-English transcription quality. Locked-
vivs auto-detect, then the ladder: (a) initial-prompt vocab injection from the vault, (b) swap to undistilledlarge-v3, (c) language-specific fine-tunes (lose code-switching). STATUS open thread #10. - Silero VAD upgrade. Energy VAD lets low-level noise through → Vietnamese hallucinations.
Silero (gate non-speech before Whisper) is the principled fix; behind a
VADprotocol for a one-file swap. STATUS open threads #11. Capture in an ADR when acted on.
- Un-ignore
Package.resolved. It's currently gitignored, which blocked staging during a prior commit. Pin it for reproducible builds (spawned task).
When you defer something in a working session, add it here in the same turn. A one-line entry with a file/ADR ref is enough. Future sessions (and future-you) build the best version of Hark by never losing the thread on what "better" looked like.