Send recent dictations and the caret's text as conversation_context; key terms as word_boost - #132
Conversation
Partially reverts #114, which switched the transcription prompt off wholesale at `TranscriptionPrompt.isEnabled` rather than narrowing it. The switch is gone and `build(context:)` is the builder again, but it reads exactly ONE field of `TranscriptionContext`: `priorText`. The prior chunk is the signal worth sending. It is what the model is mid-trained to use as contextual priming — the utterance continues the sentence the cursor is sitting in, so vocabulary, capitalization and mid-sentence continuity carry over from what the user already typed. The rest of the old prompt was inference about the user's screen: a topic hint from the window title, a destination sentence from the app and field label, and a `Selected text:` block. Those are deleted outright, not gated behind a constant — the app name, window title, field label and selected text are still captured, because the paste separator, the injector's window identity and the developer-mode log need them, and none of them can now reach `config.prompt`. There is no switch to flip the wrong way. The `Keywords: a, b, c.` clause is deleted too, for a different reason: prose was the wrong shape for a vocabulary list. The user's key terms reach nothing as of this commit; the next one sends them as the request's own word-boost field. `build` clips the prior chunk to fit `characterCap` itself rather than trusting `FocusCapture`'s upstream clip — this is the last place before the wire, and over the cap the API rejects the whole request. Note that cap is 4096 on `config.prompt` and a different number from the 2048 on `config.llm.instruction` (`CleanupInstruction.characterCap`); conflating the two is how a whole-request 400 shipped once before. The README's Privacy section is corrected in the same commit because what leaves the machine changed: it now says the request carries the text before the cursor (never from a password field, which `FocusCapture` refuses to read) and names what is *not* sent — the app, the window title, the field, the selection. Verified with `scripts/check.sh --portable` only; there is no macOS toolchain here, so Swift build/tests did not run. CI on macos-26 is the authority on green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K793aMn4CjN2EQpmq4HoKU
Commit 1 deleted the transcription prompt's `Keywords: a, b, c.` clause, which left the Settings "Key Terms" field storing terms that reached nothing. Send them properly instead: `config.keyterms_prompt`, the dictation API's own word-boost field. The two fields are siblings on one request, not alternatives. `prompt` is prose context — here, the text before the cursor — telling the model what the utterance continues; `keyterms_prompt` is a flat list of strings biasing recognition toward those exact spellings. An explicit vocabulary list of names, product names and jargon is precisely what keyterms are for, and precisely what packing them into prose was the wrong shape for. Deliberately **not** `word_boost`. That older field is deprecated and is rejected by the Universal-3 Pro family the dictation service runs, so sending it would fail the whole request rather than degrade to no boosting. The tests assert the `keyterms_prompt` key by name for that reason. `KeytermsBoost.fitted` is the only enforcement left — `KeyTermsStore.parse` already trims, drops blanks and dedupes — and it is a length cap, because the user's list is the one unbounded input in the request. It takes whole terms in the user's order while they fit, and stops at the first that doesn't: half a name boosts nothing, and the terms typed first are the ones worth keeping. That cap is 2048 characters, counted in UTF-8 bytes (the conservative reading, as with `CleanupInstruction`). The request now carries three separate caps on three separate fields — 4096 on `config.prompt`, 2048 on `config.llm.instruction`, 2048 on `config.keyterms_prompt` — so each gets its own constant; borrowing one field's figure for another is how a whole-request 400 shipped once before. `DictationLog.Entry` gains `keyterms` beside `prompt`, filled through the same builder the request uses, so the developer-mode log can't claim steering the API never saw. The README's Privacy section is corrected again, because what leaves the machine changed again: the key terms are now sent, so they move out of the "nothing else about your screen is sent" list and into the sentence naming what the request carries. Verified with `scripts/check.sh --portable` only; there is no macOS toolchain here, so Swift build/tests did not run. CI on macos-26 is the authority on green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K793aMn4CjN2EQpmq4HoKU
The two commits already on this branch turned the transcription prompt back on and added the key-terms word-boost field, but missed this bullet: it still told hosts that `config.prompt` is switched off and omitted from every request. Both halves are now wrong — the request carries `config.prompt` (the text before the cursor) and `config.keyterms_prompt` (the user's key terms), and the `TranscriptionPrompt.isEnabled` constant the sentence names no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5YKzas21LhvxUbffyJdtW
Dev buildDownload Blurt.app — built from Installing itcd ~/Downloads
unzip -o blurt-dev-build-pr-132.zip # GitHub wraps every artifact in a zip
unzip -o Blurt-dev-f5bafd4.zip
find Blurt.app -exec xattr -c {} + # clear quarantine: xattr lost -r in macOS 12.3
rm -rf /Applications/Blurt.app && cp -R Blurt.app /Applications/
open -a BlurtIt is ad-hoc signed and not notarized: Gatekeeper refuses to open it until Expect that re-grant once per dev build, including a second build of this tccutil reset Accessibility dev.alex.blurt |
CI's `swiftlint --strict` rejected the `[String]?` shape in seven places: `discouraged_optional_collection` is a deliberate zero-tolerance opt-in in `.swiftlint.yml`, and the config's own instructions say to fix violations in code rather than disable the rule. The rule is right here — an optional array makes "no terms" expressible twice over, and nothing downstream wanted the distinction. So `KeytermsBoost.fitted` returns `[String]`, empty meaning nothing to send, and the omit-vs-`[]` distinction that genuinely matters — the API must not receive `keyterms_prompt: []`, which asks to boost nothing — moves to the one place it belongs, the wire. `DictationConfig` gains an explicit `encode(to:)` that drops the key for an empty list and spells out the `encodeIfPresent` behavior synthesis was giving the optional fields. `DictationLog.Entry` gets the same treatment for the same reason: a plain array would otherwise write `"keyterms":[]` on every line of a corpus where no other absent field is written at all. Both encoders name every stored property, and both are covered by existing assertions (`configOmitsKeyterms`, `nilFieldsAreOmitted`, and the per-field entry tests), so a field added later and forgotten fails a test rather than silently vanishing. Also fixes `type_body_length`: the three new config tests pushed `HTTPClientTests` to 261 lines against a 250 limit. They move to an extension of the same suite in the same file — the device `APIKeyValidatorTests` already uses — so they keep the `configObject` and `makeTranscriber` helpers. Both failures were invisible to `scripts/check.sh --portable`, which skips the Swift linters entirely. `compile`, `format-patch` and `dev-build` were already green, so nothing here changes behavior on the wire: an empty list omitted the field before and omits it now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K793aMn4CjN2EQpmq4HoKU
| prior: context?.priorText, selected: context?.selectedText, | ||
| prompt: TranscriptionPrompt.build(context: context)) | ||
| prompt: TranscriptionPrompt.build(context: context), | ||
| keyterms: KeytermsBoost.fitted(context?.keyTerms ?? []) |
There was a problem hiding this comment.
KeytermsBoost.fitted(context?.keyTerms ?? []) persists user-provided key terms in DictationLog, which may contain personal names or other identifying free text.
Details
✨ AI Reasoning
The entry now includes KeytermsBoost.fitted(context?.keyTerms ?? []), causing user-provided key terms to be persisted by DictationLog. Key terms can contain personal names or other identifying text, so this expands the local log's collection of personal data.
🔧 How do I fix it?
Keep sensitive data such as emails, passwords, and tokens out of logs. When logging values tied to a user, prefer a safe identifier like a user ID over the raw input, and strip line breaks from any user-provided text you do log.
Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
Not changing this, but the reasoning is worth recording.
Key terms are the least sensitive field in the entry, not a new category. The same Entry already records prior (arbitrary text from before the cursor), selected, and the full prompt — free-form user prose. A comma-separated vocabulary list typed into Settings ("AssemblyAI, LeMUR, Kubernetes") is strictly less identifying than the text it sits beside. If the entry's privacy budget is the concern, prior is the field to argue about, and that predates this PR.
The remedy doesn't fit this log's purpose. dictations.jsonl exists to record what was sent to the API, as a corpus for prompt iteration; a surrogate identifier in place of the value records nothing usable. As of this PR the key terms are on the wire in config.keyterms_prompt on every request, so the log accounting for them is the point — it's built through the same KeytermsBoost.fitted the request uses precisely so the log can't claim steering the API never saw.
On the two specifics the rule names:
- Passwords/tokens can't reach here at all.
FocusCapture.mustRedactContentsrefuses to read secure fields, detected by AX role or subrole and failing closed when the role is unreadable — so a typed password never enters the context, the log, or the request. - Line breaks are already handled.
KeyTermsStore.parsesplits on commas and trims each term, and entries are JSON-encoded (DictationLog.appendLine), so a stray newline can't break the JSONL framing.
And nothing is written unless the user turns developer mode on — off by default, and the gate is checked before the context is touched (DictationLogGateTests). Agreed that an opt-in doesn't make logged data harmless, which is why the sensitive halves are excluded structurally rather than by the switch: secure fields upstream, and the sibling errors.jsonl deliberately carries no prior/selected/prompt at all.
Happy to dismiss the finding in Aikido if a maintainer agrees — leaving that call to a human rather than self-serving an ignore:.
Generated by Claude Code
There was a problem hiding this comment.
Same finding re-raised against the new turns field, and the answer is mostly the same — but one part of it is genuinely new, so recording that rather than repeating myself.
Nothing in turns is a category of data the file didn't already hold. The turns are the user's own recent dictations plus prior. Every one of those recent dictations was already written to this same log as its own line's transcript when it happened, and prior is already its own field on this line. So this is redundancy within one file, not new exposure — and the redundancy is the point: it is the only record of how much history a given request carried and what the 4096-character fit dropped, which is exactly what the log exists to answer.
What is new, and worth stating plainly: each entry now carries up to 4 KB of duplicated text (ConversationContext.characterCap, ~99 turns max), so a developer-mode log of N dictations grows by roughly 4 KB × N beyond the transcripts themselves. Bounded and linear, not unbounded — but a real change in retained volume, and a fair thing for the rule to have noticed even though the framing ("full conversation history") overstates it: history is in-memory and per-launch (RecentDictations, never persisted itself), so what lands on disk is only what a request actually sent.
The two specifics the rule names are unchanged from my earlier reply: passwords cannot reach here at all (FocusCapture.mustRedactContents refuses secure fields, by role or subrole, failing closed on an unreadable role — so no credential enters the context, the log, or the wire), and line breaks are handled (turns are JSON-encoded by DictationLog.appendLine, so a newline can't break the JSONL framing). Nothing is written unless developer mode is on, off by default, with the gate checked before the context is touched.
The suggested remedy — a surrogate identifier in place of the value — still defeats the purpose: a log whose job is "what did we send" cannot answer that with placeholders.
Still leaving the Aikido dismissal to a human rather than self-serving an ignore:.
Generated by Claude Code
Second swiftlint round: `file_length` (400, a warning that `--strict` promotes to an error) on the two files this branch grew — the transcriber at 403 and its test file at 402. The test file goes back under by moving the three `keyterms_prompt` config tests into `KeytermsWireTests.swift`, still an extension of the same `HTTPClientTests` suite. That is how `APIKeyValidatorTests.swift` is already organized, including the detail that each such file carries its own private config helper rather than widening one to share it — which also means the previous commit's extension-in-the-same-file, added only to satisfy `type_body_length`, is now a real split along the same seam the repo already uses. The transcriber test file drops to 367 lines. `AssemblyAITranscriber.swift` needed 3 lines and got 5, by tightening the comments this branch added to it — not by moving code, since the wire types and `MetricsLogger` are deliberately file-private and share a file-scoped `Logger`. It lands at 398, with the headroom the last attempt at exactly 400 didn't have. No behavior change: same encoders, same assertions, same wire format. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K793aMn4CjN2EQpmq4HoKU
`config.prompt` is gone. The prior chunk used to ride it under a `Previous transcript:` heading followed by a fixed instruction — a prose imitation of the structured field the dictation API offers for exactly this job. `ConversationContext.turns` builds that field instead: 1. the user's recent dictations, oldest first, so a stretch of dictation reads to the model as one continuing dialogue rather than N unrelated clips; 2. the text before the cursor, as the *last* turn, because it is what the utterance most immediately continues from. Recents are the history for it, so `RecentDictations.capacity` goes 3 → 100 with a separate `displayCapacity` for the ready window's three rows. `recentTurnCap` is 99, one short of the API's 100-turn maximum, so the prior chunk always has the last slot. Over the 4096-character cap the oldest turns are dropped first — the same rule the server applies — and the kept turns are a contiguous newest run, since a half-sent turn reads as how the speaker actually talks. The cap counts characters, not the UTF-8 bytes the other two caps use: those are 400-on-overflow, this field is documented as trimmed, so matching the documented unit beats over-shooting it. The boost list moves to `config.word_boost`, which is the name the dictation API's own reference documents; `keyterms_prompt` is the sibling Sync surface's name and only ever arrived as a forwarded unknown field. The `llm` cleanup-rewrite block is untouched. Two consequences of dropping the prompt are deliberate: the service's managed default transcription prompt applies again, and `config.language_code` is no longer ignored (the API ignores it whenever a custom prompt is set). Verified against the live endpoint: the app's own encoded config returns 200 with both fields honored, not merely tolerated — word_boost turns "Assembly AI" into "AssemblyAI", and conversation_context turns "Reid" into "Reed" from the prior turns alone. 100 turns and a deliberately over-cap 150-turn/7990-character list both return 200, confirming the server trims rather than rejects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| /// calls that construct an entry directly from a context. Worth recording | ||
| /// separately from `prior` because it is the only record of how much history | ||
| /// the request carried, and of what the 4096-character fit dropped. | ||
| let turns: [String] |
There was a problem hiding this comment.
The new turns field logs recent dictations and cursor text, which may contain personal data. Avoid persisting the full conversation history in DictationLog.Entry.
Details
✨ AI Reasoning
The developer-mode JSONL log now records the full conversation_context turns, including prior user dictations and text before the cursor. These are free-text inputs submitted by the user and can contain names, messages, credentials, or other identifying information. The existing transcript logging predates this change, but this newly added history expands the personal data retained on disk.
🔧 How do I fix it?
Keep sensitive data such as emails, passwords, and tokens out of logs. When logging values tied to a user, prefer a safe identifier like a user ID over the raw input, and strip line breaks from any user-provided text you do log.
Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
`conversationContext`'s default argument was dead — production and both test helpers pass it — and `wordBoost`'s was reachable from exactly one helper. Dropping both makes what a given request does and does not steer with readable at the call site instead of inferred from which argument was left off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… caret Three follow-ups from reviewing the conversation_context change. **One ring, not two.** `DictationSession` now owns the only `RecentDictations` — it assembles each request inside its actor, so a ring held as MainActor UI state couldn't be read at press time without a hop. `onTranscriptDelivered` hands the updated ring out alongside the transcript and `AppCoordinator.recentDictations` becomes a projection it assigns wholesale, so the "Recent" list can't drift from the history that was actually sent. Two things fell out: the app no longer stamps entries (the session does, at record time, closing the drift window rather than mitigating it), and the feed dropped from `.unbounded` to `.bufferingNewest(1)` — each element is now the whole ring rather than a delta, so a value lost under contention is one the next already contains. **A password dictated into a secure field is not remembered.** `FocusCapture` already refused to *read* secure fields but computed that verdict and threw it away; it now rides out as `FocusedFieldContext.isSecure` → `TranscriptionContext.targetIsSecure` (local only, never sent), and the session declines to record such a transcript. Otherwise the secret leaked the other way: replayed as a context turn on every later dictation this launch, in unrelated apps. `isEmpty` has to count the flag, because a secure field yields no prior or selected text and the snapshot would otherwise collapse to nil on the way through `contextStream`, taking the flag with it. **No more sending the same sentence twice.** After a paste, the prior chunk *is* what was just dictated, which was also the newest history turn — so every continuous dictation showed the model one utterance as two consecutive turns. Trailing history turns the chunk already ends with are now peeled off one at a time, so a whole run into one field collapses; matching is on the suffix, and stops at the first miss, since an interrupted run means earlier turns are genuine history. Docs: `BLURTENGINE.md`, `.claude/agents/cleanup-reviewer.md`, both READMEs and two test comments still documented `TranscriptionPrompt`/`config.prompt` as live and instructed that `word_boost` "the model family rejects outright". `cleanup-reviewer` is loaded verbatim by review agents, so that was a standing instruction to rename a live-verified field back. It now records why `word_boost`, the absent `config.prompt`, `prior` vs `turns.last`, and `capacity` vs `displayCapacity` are each deliberate. README's privacy section said only the text before the cursor was sent; it now states the history, its caps, that it is memory-only and cleared on quit, and that text dictated in one app can reach a later request in another. The Features bullet claiming each utterance rides "as audio and nothing else" was already wrong before this branch and is corrected too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No code change — the request already carried neither `config.prompt` nor `config.language_code`. What was missing was evidence and a guard. The worry was real on paper: the API documents `language_code` as defaulting to `en` and as ignored while a custom `prompt` is set, so dropping the prompt un-ignored it, and English-pinning is a settled decision that was reverted once for hurting non-English speech. Measured against the live endpoint instead of reasoned about — with neither field set, Spanish, French, German and Japanese clips each came back correctly transcribed in their own language, and the cleanup rewrite left the language alone rather than translating. So detection works, and setting a language would only take it away. `KeytermsWireTests` now asserts the config carries no `language_code` / `language_codes` and is exactly `sample_rate` + `channels` + `llm` when there is nothing to steer with, so the absence is a tested decision rather than an oversight waiting to be "fixed". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate shipped in the previous commit looks like it enforces AGENTS.md's settled-decisions table, but nothing tied a rule to a row — and the rows move. PR #132 rewrote the config.prompt and language entries three weeks ago. A rule that outlives its row is the one failure this file cannot survive: it keeps firing, keeps citing AGENTS.md, and keeps sounding authoritative while enforcing a decision the project has reversed. That is strictly worse than the prose it replaced — prose that no longer reflects the design gets read and ignored, a gate that no longer reflects the design blocks the change that reflects it. Each rule now carries a verbatim slice of its "Don't" cell and of its bullet in the guardrails skill, and --self-test asserts both still resolve. The table anchor is matched inside the table section only (sed-extracted), so a deleted row cannot keep its pin alive by being mentioned in passing elsewhere in the guide; a failed extraction reports itself as a moved heading rather than as twelve simultaneously-unpinned rules. The skill anchors are deliberately not the table's wording — the skill says the same things differently, and a pin that assumed identical text would only be checking that someone had copy-pasted. This also makes CLAUDE.md's "keep the two in agreement when you change either" cost one grep instead of a reviewer's memory, for the twelve decisions that are mechanized. The remaining rows stay a matter of care, and the docs now say which is which. The failure message asks for a decision rather than a patch: reworded row, update the anchor; reversed decision, delete the rule with it. Verified: deleting a row, rewording a row, dropping a skill bullet, and renaming the table heading each fail --self-test with the right message; the clean tree passes; scripts/check.sh --portable is green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TACi7k2226no2tVcR3YJr1
main moved to d8491e2 (#132 conversation_context/word_boost, #135 engine docs), which made the PR un-mergeable. Only BLURTENGINE.md conflicted; the Swift auto-merged and both of this branch's DictationSession changes (setPhase(.connecting), mic.cancelCapture()) survived intact. Three doc conflicts, all resolved by taking main's rewritten prose and re-applying this branch's additions on top: - The "cleanup happens server-side" bullet is main's verbatim — it now describes conversation_context/word_boost, and the version here still described the deleted TranscriptionPrompt. - The projections bullet takes main's text with `.connecting` re-inserted into the OverlayUIState list and the menu-bar clause. - The settled-decisions rows combine both edits: CoreAudio joins the allowed-imports line, and transcription steering points at ConversationContext rather than the now-deleted TranscriptionPrompt. Also corrected a sentence main added while this branch was open: its new "Record cues" section described the chime as firing on the idle→recording edge, which stopped being the whole story once `.connecting` landed in front of `.recording`. It now names the connecting→recording edge and why the chime must not fire at the press. main did not touch RecordingCueGate, PipelinePhase, OverlayUIState or MenuBarStatus, so there was no semantic overlap in the projections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
What & why
The request's transcription steering is now the structured field the dictation API offers for it.
config.promptis gone.Context is the dialogue that came before, as ordered turns.
ConversationContext.turnsemits the user's recent dictations oldest-first, then the text before the caret as the last turn — the thing the utterance most immediately continues from. So a stretch of dictation reads to the model as one continuing dialogue instead of N unrelated clips. The old prompt carried the same prior chunk as prose under aPrevious transcript:heading plus a fixed instruction; that was an imitation of this field.Dropping the prompt buys two things back: the service's managed default transcription prompt applies again (a custom prompt replaces it), and
config.language_codeis no longer ignored.The history is
RecentDictations, now 100 deep withdisplayCapacity3. The ready window's "Recent" list was already this type; it just showed everything it held. Depth is now a transcription-quality question, not a layout one.DictationSessionowns the one ring — it assembles each request inside its actor, so a ring held as MainActor UI state couldn't be read at press time without a hop — and pushes the updated value to the app throughonTranscriptDelivered, which makes the list a projection that can't drift from what was sent.Two caps, both enforced here rather than assumed of the caller: at most
recentTurnCap(99) recent dictations, one short of the API's 100-turn maximum so the prior chunk always has the last slot; and 4096 characters total, fitted by dropping the oldest turns first — the same rule the server applies. Kept turns are a contiguous newest run, since a half-sent turn reads as how the speaker actually talks. This cap counts characters, not the UTF-8 bytes the other two caps use: those are 400-on-overflow, this field is documented as trimmed.What leaves the machine — read this bit
This branch widens it. Up to ~4096 characters of the user's previous transcripts now ride every request, where before it was just the prior chunk. Three guards:
FocusCapturealready refused to read a password field; a transcript dictated into one used to be recorded and then replayed as a context turn on every later dictation, in unrelated apps.FocusedFieldContext.isSecure→TranscriptionContext.targetIsSecure(local only, never sent) now stops that.isEmptycounts the flag, or the snapshot would collapse tonilon the way throughcontextStreamand take it with it.README.md's Privacy section is rewritten to say all of this plainly, including that text dictated in one app can reach a later request in another. Its Features bullet claiming each utterance rides "as audio and nothing else" was already wrong before this branch and is corrected too.Two settled decisions changed — worth a reviewer's eye
word_boost, notkeyterms_prompt. The repo previously documentedword_boostas deprecated and "rejected outright by the Universal-3 Pro family". The dictation API's own reference documentsword_boostas the parameter;keyterms_promptis the sibling Sync STT surface's name and only ever reached the dictation engine as a forwarded unknown field. Verified live (below). The aliases are mutually exclusive, so exactly one is sent.config.language_code. The API documents that field as defaulting toen, so un-ignoring it looked like it might re-pin transcription to English — a decision reverted once for hurting non-English speech. Measured instead of assumed: with neither field set, es/fr/de/ja clips each came back in their own language.KeytermsWireTestsasserts the absence so it can't be "fixed" later.BLURTENGINE.md,.claude/agents/cleanup-reviewer.md,AGENTS.md,project-guardrailsand the evals README all still describedTranscriptionPrompt/config.promptas live and instructed thatword_boostfails the request. Sincecleanup-revieweris loaded verbatim by review agents, that was a standing instruction to revert a live-verified field name. All six sites now agree, and record why the deliberate overlaps (priorvsturns.last,capacityvsdisplayCapacity) exist.How it was tested
scripts/check.shgreen on a Mac: 541 tests, app builds, swift-format/swiftlint/periphery/prettier clean. The UI suite and leak scan are skipped locally — CI is the authority there.Verified against the real endpoint (
POST https://dictation.assemblyai.com/transcribe), posting the config the app's own encoder produces:text+llm_response, rewrite dropped the disfluenciesword_boostA/Bconversation_contextA/Bllm.instructionvsllm: {}vs nollmThe last three matter most: they show each field is honored, not merely tolerated.
scripts/check.shpasses🤖 Generated with Claude Code