Skip to content

Send recent dictations and the caret's text as conversation_context; key terms as word_boost - #132

Merged
alexkroman merged 9 commits into
mainfrom
claude/previous-context-inclusion-sxotsi
Aug 13, 2026
Merged

Send recent dictations and the caret's text as conversation_context; key terms as word_boost#132
alexkroman merged 9 commits into
mainfrom
claude/previous-context-inclusion-sxotsi

Conversation

@alexkroman

@alexkroman alexkroman commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

What & why

The request's transcription steering is now the structured field the dictation API offers for it. config.prompt is gone.

- "prompt": "Previous transcript:\nthanks for\n\nTranscribe without speaker labels, …",
- "keyterms_prompt": ["AssemblyAI"],
+ "conversation_context": ["Shipping the release notes now.", "thanks for"],
+ "word_boost": ["AssemblyAI"],

Context is the dialogue that came before, as ordered turns. ConversationContext.turns emits 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 a Previous 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_code is no longer ignored.

The history is RecentDictations, now 100 deep with displayCapacity 3. 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. DictationSession owns 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 through onTranscriptDelivered, 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:

  • Secure fields are not remembered. FocusCapture already 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.isSecureTranscriptionContext.targetIsSecure (local only, never sent) now stops that. isEmpty counts the flag, or the snapshot would collapse to nil on the way through contextStream and take it with it.
  • No double-sending. After a paste the prior chunk is what was just dictated, which was also the newest history turn — every continuous dictation showed the model one utterance twice. Trailing history turns the chunk already ends with are peeled off one at a time, so a whole run into one field collapses.
  • Memory only, cleared on quit, never written to disk.

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, not keyterms_prompt. The repo previously documented word_boost as deprecated and "rejected outright by the Universal-3 Pro family". The dictation API's own reference documents word_boost as the parameter; keyterms_prompt is 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.
  • No language, at all. Not a prompt directive and not config.language_code. The API documents that field as defaulting to en, 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. KeytermsWireTests asserts the absence so it can't be "fixed" later.

BLURTENGINE.md, .claude/agents/cleanup-reviewer.md, AGENTS.md, project-guardrails and the evals README all still described TranscriptionPrompt/config.prompt as live and instructed that word_boost fails the request. Since cleanup-reviewer is 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 (prior vs turns.last, capacity vs displayCapacity) exist.

How it was tested

scripts/check.sh green 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:

Probe Result
App's exact config + speech 200; text + llm_response, rewrite dropped the disfluencies
100 turns (99 history + prior chunk) 200
150 turns / 7990 chars 200 — over-cap is trimmed, not rejected
word_boost A/B "Assembly AI" → "AssemblyAI"
conversation_context A/B "Then Reid said" → "Then Reed said", from the prior turns alone
llm.instruction vs llm: {} vs no llm Distinct outputs — ours removes "So"/"I mean"/"you know" and preserves punctuation
es / fr / de / ja, no language field Each transcribed in its own language; rewrite didn't translate

The last three matter most: they show each field is honored, not merely tolerated.

  • scripts/check.sh passes
  • I read AGENTS.md and this doesn't reintroduce anything deliberately removed — two settled decisions are changed deliberately and re-documented, called out above
  • Docs updated if behavior changed

🤖 Generated with Claude Code

claude added 2 commits August 13, 2026 00:54
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
Comment thread Sources/BlurtEngine/Pipeline/DictationLog.swift Outdated
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
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Dev build

Download Blurt.app — built from f5bafd4, Debug-Local,
ad-hoc signed. Expires in 14 days.

Installing it
cd ~/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 Blurt

It is ad-hoc signed and not notarized: Gatekeeper refuses to open it until
the quarantine flag is cleared, and macOS treats it as a different app from a
released Blurt, so you have to re-grant Microphone, Accessibility, and Input
Monitoring. Reinstall the release DMG
when you are done reviewing.

Expect that re-grant once per dev build, including a second build of this
same PR. TCC pins an Accessibility grant to the signature that took it, and an
ad-hoc signature is just a hash of the binary, so every build is a new app as far
as tccd is concerned. Blurt clears the orphaned grant at launch, which is what
keeps the Accessibility step from getting stuck on a Blurt row that is switched on
and still denied. If you are coming from a build old enough to predate that,
clear the grant yourself once:

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 ?? [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.mustRedactContents refuses 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.parse splits 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

claude and others added 2 commits August 13, 2026 01:19
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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@alexkroman alexkroman closed this Aug 13, 2026
alexkroman-assembly and others added 3 commits August 13, 2026 10:43
`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>
@alexkroman alexkroman changed the title Send the prior chunk as transcription context, and key terms as word boosting Send recent dictations and the caret's text as conversation_context; key terms as word_boost Aug 13, 2026
@alexkroman alexkroman reopened this Aug 13, 2026
@alexkroman
alexkroman enabled auto-merge August 13, 2026 17:56
@alexkroman
alexkroman added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit d8491e2 Aug 13, 2026
13 of 21 checks passed
@alexkroman
alexkroman deleted the claude/previous-context-inclusion-sxotsi branch August 13, 2026 18:20
alexkroman pushed a commit that referenced this pull request Aug 13, 2026
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
alexkroman pushed a commit that referenced this pull request Aug 13, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants