Add app-kind guidance to transcription prompts - #98
Conversation
Recognize what kind of app the dictation targets from the frontmost
app's bundle identifier and add one destination-specific priming
sentence to the dictation request's config.prompt:
- terminals ("You are dictating into a terminal: expect shell commands,
program names, flags, and file paths.")
- code editors, naming the language inferred from the window title's
filename ("You are writing Python in a code editor: ...")
- Slack ("... casual tone and emoji are expected.")
- Obsidian ("You are writing a Markdown note in Obsidian: ...")
New pure AppKindPriming type owns the bundle-ID -> kind table (exact
matches plus JetBrains/Sublime prefix families), the filename-extension
-> language map, and the clause wording (positive phrasing per the
Universal-3 Pro prompting guidance). TranscriptionContext gains a
bundleID field, captured alongside the process name in FocusCapture and
counted by isEmpty; TranscriptionPrompt places the guidance after the
existing destination sentence, before baseInstruction. Unrecognized
apps add nothing, so existing prompts are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R28wj6vXZDW3iS7FWpHT1U
There was a problem hiding this comment.
Pull request overview
Adds destination “app-kind” priming to the engine’s transcription prompt generation by recognizing the frontmost app’s bundle identifier (terminal, code editor, Slack, Obsidian) and injecting a guidance sentence that steers the STT model toward the expected shape of text (commands, code, chat, Markdown). This fits the engine’s existing “contextual priming” approach by enriching TranscriptionContext at press time and integrating the new clause into TranscriptionPrompt.build(context:).
Changes:
- Introduces
AppKindPriming(bundle-id → kind recognition + optional language inference from window titles) and integrates its clause intoTranscriptionPrompt. - Extends
TranscriptionContextto carrybundleID, and populates it fromFocusCapture→DictationSession. - Adds/updates Swift Testing coverage for app-kind recognition, language inference, and prompt integration; updates engine docs to reflect the new priming behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/BlurtEngineTests/TranscriptionPromptTests.swift | Adds prompt expectations covering app-kind guidance insertion and language-specific code-editor guidance. |
| Tests/BlurtEngineTests/TranscriptionContextTests.swift | Verifies bundleID affects emptiness/prompt production and participates in Equatable. |
| Tests/BlurtEngineTests/AppKindPrimingTests.swift | New test suite covering bundle-id kind recognition, language inference, and clause rendering. |
| Sources/BlurtEngine/STT/TranscriptionPrompt.swift | Appends AppKindPriming guidance after the destination sentence when recognized. |
| Sources/BlurtEngine/STT/TranscriptionContext.swift | Adds bundleID to the context model and emptiness logic. |
| Sources/BlurtEngine/STT/AppKindPriming.swift | New engine component for bundle-id based app-kind recognition and guidance clause rendering. |
| Sources/BlurtEngine/Pipeline/DictationSession.swift | Includes captured bundle ID in the constructed TranscriptionContext at press time. |
| Sources/BlurtEngine/FocusCapture/FocusCapture.swift | Captures NSRunningApplication.bundleIdentifier into CapturedFocus. |
| BLURTENGINE.md | Documents the new bundleID field and app-kind guidance behavior in prompting. |
| AGENTS.md | Updates the repository map and prompt-building description to include AppKindPriming. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
DictationLog already writes the fully-assembled config.prompt with every entry, so the new app-kind guidance reaches the corpus automatically. Complete the context snapshot by also logging the frontmost app's bundle identifier — the input AppKindPriming keys on — so the log shows why a prompt carried (or lacked) a guidance sentence, and pin with a test that the guidance actually lands in the logged prompt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R28wj6vXZDW3iS7FWpHT1U
The dictation service's own default prompt already carries "Transcribe without speaker labels, audio event descriptions, or emotion markers.", so restating it as baseInstruction on every request only spent budget from the 4096-character prompt cap. The built prompt is now contextual priming only: prior text, selected text, the location clause (topic + destination + app-kind guidance), and trailing keyword boosting. A context that renders no text (e.g. its only signal is an unrecognized bundle ID) now collapses to nil so the server default still applies, pinned by new test cases alongside the rewritten prompt expectations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R28wj6vXZDW3iS7FWpHT1U
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
Sources/BlurtEngine/STT/AppKindPriming.swift:140
scssfiles are currently labeled as "CSS" in the language inference map, which will produce incorrect guidance (SCSS is distinct from CSS). Consider mappingscssto "SCSS" so the clause accurately reflects the file type.
private static let languagesByExtension: [String: String] = [
"c": "C", "h": "C",
"cc": "C++", "cpp": "C++", "cxx": "C++", "hpp": "C++",
"cs": "C#",
"css": "CSS", "scss": "CSS",
"go": "Go",
…ompt
Real-world dictation logs showed the contextual blocks crowding the
instruction — a VS Code dictation carried the Monaco screen-reader help
announcement as its "field", plus topic and destination sentences ahead
of the actual instruction. Strip config.prompt down to two optional
clauses: the app-kind instruction from AppKindPriming, reworded to the
"Transcribe speech into ..." form ("... into markdown." in Obsidian,
"... into Swift code." in a code editor, shell commands in a terminal, a
casual Slack message with emoji in Slack), and the trailing
"Keywords: ..." boost fitted to the 4096-character cap.
The rest of the focus capture (app/field names, window title, prior and
selected text) no longer renders into the prompt but is still captured:
it feeds the dictation log, the injector's separator logic, and the
code-editor language refinement. A context whose signals render nothing
now yields no prompt at all, so the server default applies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R28wj6vXZDW3iS7FWpHT1U
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
Sources/BlurtEngine/STT/AppKindPriming.swift:109
language(inWindowTitle:)currently splits only on whitespace, but the doc comment and real window titles often use em/en-dash separators without surrounding spaces (e.g. "main.py—blurt"). In that case the filename and the suffix stay in one token, the extension becomes "py—blurt", and language inference fails.
static func language(inWindowTitle title: String) -> String? {
for token in title.split(whereSeparator: \.isWhitespace) {
let name = token.trimmingCharacters(in: Self.filenameTrim)
// A leading-dot name (".zshrc") is a dotfile, not a base name + extension.
guard let dot = name.lastIndex(of: "."), dot != name.startIndex else { continue }
if let language = languagesByExtension[name[name.index(after: dot)...].lowercased()] {
The dictation log now writes each entry as the transcript that came back, the timestamp, and the exact config.prompt that was sent — nothing else. The raw focus context (app and field names, window title, prior/selected text) is not sent to the service, so it stays off disk entirely, even with developer mode on; the gate test now pins that prior text never reaches the log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R28wj6vXZDW3iS7FWpHT1U
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
Tests/BlurtEngineTests/TranscriptionPromptTests.swift:15
- The header comment still says focus signals are captured for the dictation log, but DictationLog now deliberately writes only the assembled prompt (not raw app/window/field/prior/selected context). This comment should reflect that these signals are captured for local behavior (e.g., separator logic / language inference) but are not logged verbatim.
/// The prompt is the app-kind instruction plus trailing keyword boosting —
/// nothing else. The other focus signals (app name, window title, field
/// label, prior text, selected text) are captured for the dictation log and
/// the injector, and must never surface in the prompt; the cases below pin
/// both directions.
Sources/BlurtEngine/STT/TranscriptionPrompt.swift:19
- This doc comment says the non-rendered focus context is captured "feeding the dictation log", but the log now intentionally persists only the assembled prompt. Consider rewording to avoid implying raw focus fields are logged.
/// is still captured, feeding the dictation log, the injector's separator
/// logic, and the code-editor language refinement above. Also deliberately
Sources/BlurtEngine/STT/AppKindPriming.swift:19
- The PR description says unrecognized apps fall back to the existing destination sentence, but the updated TranscriptionPrompt/AppKindPriming behavior appears to emit no prompt at all for unrecognized apps (server default applies). Please align the PR description with the implemented behavior, or reintroduce the fallback if that was intended.
/// Detection keys on bundle IDs, not display names: names are localized and
/// user-editable, while the bundle ID is the app's stable identity. An
/// unrecognized app contributes no clause — the request then carries no
/// instruction and the service's own default prompt applies. Wording follows
Sources/BlurtEngine/FocusCapture/FocusCapture.swift:58
- In this doc block, "adds no new prompt" is ambiguous now that this PR is about the STT prompt (it reads like it could mean the transcription prompt). Also, the secure-field wording implies leakage into the injector; the key privacy guarantee is that sensitive field contents are never read (so they can't affect behavior or reach disk).
///
/// Secure text fields (password inputs) are detected by role **or** subrole and
/// never have their contents read, so a typed password — selected or not — can't
/// leak into the dictation log or the injector. The check fails closed: an
/// unreadable role is treated as secure, since it can't be shown not to be.
`config.prompt` was carrying two things it cannot act on. The Sync STT
reference is explicit that the field takes a *description of the audio*
("Cardiology consultation about chest pain symptoms."), not instructions —
transcription behavior is optimized out of the box — so the app-kind
imperative ("Transcribe speech into markdown.") was aimed at a field that
does not reshape output. That is the same category as the filler-removal
clause dropped earlier for being a no-op; it was never a wording problem.
The docs likewise warn against packing keyword lists into the prompt.
Stop sending `prompt` and use the three fields that each do one job:
- `conversation_context` — the text before the cursor, as a single turn.
Real left-context, so the model knows what the utterance continues. This
was captured all along and thrown away at the request boundary.
- `keyterms_prompt` — the user's key terms verbatim, replacing the inline
`Keywords: a, b, c.` clause, refitted to the field's 2048-char total.
- `llm.instruction` — the app-kind clause, reworded from "Transcribe speech
into X" to "Format the result as X" now that it addresses the LLM that
rewrites the finished transcript rather than the STT decoder.
Sending no prompt also keeps the service's managed default, which a custom
value replaces wholesale *including its language steering* — the mechanism
behind the earlier finding that pinning the prompt to English hurt
non-English speech.
`TranscriptionPrompt` becomes `TranscriptionSteering`, returning all three
fields so the transcriber and the dictation log describe one request instead
of each deriving it. Empty fields are omitted rather than sent as `[]`.
The log now records every field that was sent, under the wire's own names.
Prior-cursor text is on the sent side of that line, so it reaches
`~/Library/Logs/Blurt/dictations.jsonl` for a user who turned developer mode
on. What keeps a password out of it is unchanged and upstream: FocusCapture
skips prior and selected text in secure fields, failing closed when the AX
role can't be read. Selected text is still never sent — the paste replaces
it, so priming on it would condition the model on text on its way out.
Verified with `swift test` (435 tests, 68 suites); the full `scripts/check.sh`
has not been run on this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| /// from `context` (rather than threaded through from the transcriber) so the | ||
| /// log always reflects what was actually sent, even for calls that construct | ||
| /// an entry directly from a context. | ||
| let conversationContext: [String] |
There was a problem hiding this comment.
Entry now includes conversationContext and keytermsPrompt (user prior text and user-provided terms) that are written verbatim to the JSONL log. Consider sanitizing or avoiding storage of raw priorText/keyterms to reduce personal-data leakage.
Details
✨ AI Reasoning
A new logged Entry now includes the per-utterance steering fields that can contain user-controlled textual content: the conversation context (prior-cursor text) and keyterms list. These fields originate from the user's focus context and settings and can contain personal data or arbitrary user-entered strings. Writing them verbatim into the append-only JSONL log (even gated by developer mode) increases the risk of storing unsanitized user input and leaking personal data. The change also hand-encodes these fields so they will be emitted directly when present.
🔧 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.
@AikidoSec ignore: The flagged fields don't exist — Entry is transcript/ts/prompt only (no conversationContext/keytermsPrompt), and the latest commit (15e1718) specifically removed all raw focus context (prior text, selected text, app/field/window) from the log, pinned by DictationLogTests including a case asserting prior text never reaches disk. The logged prompt is exactly the string already sent to the transcription API (at most an app-kind instruction plus the user's own Settings key terms), and the whole log is opt-in behind developer mode, off by default.
Generated by Claude Code
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
The flagged fields don't exist —
Entryistranscript/ts/promptonly (noconversationContext/keytermsPrompt), and the latest commit (15e1718) specifically removed all raw focus context (prior text, selected text, app/field/window) from the log, pinned byDictationLogTestsincluding a case asserting prior text never reaches disk. The loggedpromptis exactly the string already sent to the transcription API (at most an app-kind instruction plus the user's own Settings key terms), and the whole log is opt-in behind developer mode, off by default.
Generated by Claude Code
There was a problem hiding this comment.
Correction to my ignore reason above: it was written against a stale checkout (15e1718) — on the current head (4c9a7e5) Entry does carry conversation_context and keyterms_prompt. The ignore disposition still stands, for the accurate reason: those fields are logged because they are now sent to the dictation API as its documented steering fields, and the log's contract is to record exactly the request that was made (DictationLogTests.logsOnlyWhatWasSent pins that captured-but-unsent context stays off disk). Writing is opt-in behind developer mode (off by default, nothing touches disk otherwise), and secure-field text can never reach these fields — FocusCapture skips prior/selected text in password fields, failing closed on an unreadable role.
Generated by Claude Code
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
Sources/BlurtEngine/FocusCapture/FocusCapture.swift:8
- This comment refers to a “prompt”, but the dictation request no longer sends
config.prompt; the bundle ID is used to selectllm.instructionviaAppKindPriming(request steering). Updating the wording avoids implying a prompt-based mechanism that no longer exists.
/// The frontmost app's stable identity, feeding the prompt's app-kind
/// recognition (`AppKindPriming`) via `TranscriptionContext.bundleID`.
Sources/BlurtEngine/STT/AssemblyAITranscriber.swift:213
- The documentation for
conversationContextsays it is encoded only when non-nil/usingencodeIfPresent, but this property is non-optional and is omitted when empty by the customencode(to:)implementation. Updating the comment will keep it aligned with the actual wire behavior.
/// Turns preceding this utterance (Blurt sends at most one: the text before
/// the insertion point). Encoded only when non-nil — the synthesized
/// `encode` uses `encodeIfPresent` for optionals — so an utterance with no
/// prior text omits the field instead of sending `[]`.
let conversationContext: [String]
/// The user's key terms as the explicit vocabulary list, omitted when empty.
let keytermsPrompt: [String]
Tests/BlurtEngineTests/TranscriptionSteeringTests.swift:6
- PR description says
TranscriptionPromptTests.swiftwas updated with new cases, but in this PR the file is deleted and prompt-based logic appears removed in favor ofTranscriptionSteering. Please update the PR description’s “Test coverage” bullets to match the actual changes (e.g., mentionTranscriptionSteeringTests/AppKindPrimingTestsinstead).
@Suite("TranscriptionSteering")
struct TranscriptionSteeringTests {
CI's periphery scan fails on Fields.isEmpty — production encodes each steering field's presence individually, so the predicate was referenced only from tests. Fields is Equatable and production already uses .empty, so the tests compare against that instead of keeping a separate emptiness rule that could drift from the fields themselves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R28wj6vXZDW3iS7FWpHT1U
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
Sources/BlurtEngine/STT/AssemblyAITranscriber.swift:210
- This comment mentions
encodeIfPresent/optionals, butconversationContextis a non-optional array and omission is implemented via the customencode(to:)when the array is empty. Updating the comment keeps the docs consistent with the encoding behavior.
/// Turns preceding this utterance (Blurt sends at most one: the text before
/// the insertion point). Encoded only when non-nil — the synthesized
/// `encode` uses `encodeIfPresent` for optionals — so an utterance with no
/// prior text omits the field instead of sending `[]`.
Sources/BlurtEngine/STT/AssemblyAITranscriber.swift:112
- The doc comment implies a nil rewrite instruction omits the whole field, but the code always sends an
llmobject and only omits theinstructionkey when nil. Clarifying this here avoids confusion about what is actually on the wire.
This issue also appears on line 207 of the same file.
/// Builds the JSON `config` part sent alongside the audio. Each steering field
/// is included only when it carries something — an empty array or a nil
/// instruction omits the field rather than stating an empty value, so the
/// service applies its own default. The `llm` block always rides along — see
/// `DictationConfig.llm`. Internal so tests can assert the steering wiring
BLURTENGINE.md:155
- This sentence says only bundle ID/window title/key terms reach the request, but
TranscriptionSteeringalso sends the prior-cursor text asconversation_context. The doc should include prior text in the “sent” set so the privacy/behavior contract is accurate.
- **`TranscriptionContext`** carries the frontmost app name and bundle identifier, window title, focused-field label, the text before the caret, the selected text (which a paste will replace), and the user's key terms. It's captured via Accessibility at press time (skipped in secure fields), off the hot path — and consumed at transcribe time with a bounded wait (`DictationSession.contextWaitBudget`), so a hung read is abandoned rather than stalling the transcript. Only the bundle ID, window title, and key terms reach the prompt; the prior text and window title also steer the injector's paste separator, and nothing else is consumed.
Sources/BlurtEngine/STT/AppKindPriming.swift:10
- PR description examples mention guidance like “You are dictating into a terminal: expect shell commands, …”, but the implemented clause is purely a formatting instruction (e.g. “Format the result as a shell command …”) and
config.promptis not used. Please align the PR description (or the clause text) so the documented behavior matches what ships.
/// The app-kind formatting instruction: recognizes what *kind* of app the
/// dictation targets (a terminal, a code editor, Slack, Obsidian) from the
/// frontmost app's bundle identifier and renders the one sentence
/// `TranscriptionSteering` sends as `config.llm.instruction` — "Format the
/// result as a shell command with no trailing period." / "… as Swift code." /
/// "… as a casual Slack message, using Slack emoji where they fit." / "… as
/// markdown." — telling the rewrite what shape of text the destination expects,
/// which the app's display name alone doesn't convey.
CI's prettier --check failed on the AGENTS.md rewrap from 4c9a7e5; everything else in the run was green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R28wj6vXZDW3iS7FWpHT1U
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
Sources/BlurtEngine/STT/AssemblyAITranscriber.swift:210
- The comment above
conversationContextsays it’s “encoded only when non-nil” viaencodeIfPresent, but the property is non-optional and the field is actually omitted when the array is empty by the customencode(to:). Updating this avoids misleading future edits (especially around the empty-vs-omitted wire semantics).
/// Turns preceding this utterance (Blurt sends at most one: the text before
/// the insertion point). Encoded only when non-nil — the synthesized
/// `encode` uses `encodeIfPresent` for optionals — so an utterance with no
/// prior text omits the field instead of sending `[]`.
BLURTENGINE.md:155
- This sentence contradicts the new steering design: prior text is sent now (as
conversation_context), while the window title is not sent verbatim (it’s only used to infer a code-editor language and for injector separator fallback). The doc should reflect what is actually sent vs. only consumed locally.
- **`TranscriptionContext`** carries the frontmost app name and bundle identifier, window title, focused-field label, the text before the caret, the selected text (which a paste will replace), and the user's key terms. It's captured via Accessibility at press time (skipped in secure fields), off the hot path — and consumed at transcribe time with a bounded wait (`DictationSession.contextWaitBudget`), so a hung read is abandoned rather than stalling the transcript. Only the bundle ID, window title, and key terms reach the prompt; the prior text and window title also steer the injector's paste separator, and nothing else is consumed.
Sources/BlurtEngine/Pipeline/DictationLog.swift:13
- The log schema claims to use “the request's own wire names”, but
llm_instructionis not a wire key (the request sendsllm: { instruction: … }). Either log the nestedllmobject, or clarify in the comment that this field is a flattened representation ofllm.instruction.
/// One logged dictation: what came back (`transcript`), when, and exactly what
/// was sent to steer it. Fields carry the request's own wire names so a log
/// line reads as the request it describes.
Tests/BlurtEngineTests/DictationLogTests.swift:13
- Test comment says the steering fields are logged “under the same wire names the request uses”, but
llm_instructionis a flattened key (wire format isllm.instruction). Clarifying this keeps the tests from encoding an incorrect contract in comments.
/// Decodes the steering fields so tests can assert the exact request
/// customization that was sent is what lands on disk, under the same wire names
/// the request uses.
Rolls the tree back to e08a9ce (v0.1.34) by reverting everything merged since, newest first: - AssemblyAI#101 chore: bump to v0.1.36 - AssemblyAI#100 feat(stt): stop sending anything about the running app to the LLM - AssemblyAI#99 chore: bump to v0.1.35 - AssemblyAI#98 Add app-kind guidance to transcription prompts AssemblyAI#98 and AssemblyAI#100 partly cancelled — AssemblyAI#100 removed the app-kind clause AssemblyAI#98 added — but AssemblyAI#98 carried much more than that, and all of it is undone here. Restored as a result: - `config.prompt` is sent again, built by `TranscriptionPrompt` from the focused app, window, and field plus the user's key terms and the text around the cursor. - `TranscriptionSteering` is gone, and with it the split into `conversation_context`, `keyterms_prompt`, and `llm.instruction`. Key terms are back to a `Keywords: a, b, c.` clause inside the prompt; prior-cursor text is contextual priming in the prompt rather than a conversation turn. - `DictationLog` records the assembled prompt again, alongside the frontmost app's bundle ID. - `AppKindPriming` returns with AssemblyAI#98 and is not re-removed: AssemblyAI#100's removal was reverted too, so the bundle-ID → app-kind table is live again. Docs (AGENTS.md, BLURTENGINE.md, README.md, the project-guardrails skill) go back to describing that design. `CFBundleShortVersionString` is 0.1.34. `git diff e08a9ce` is empty, so this is an exact rollback rather than an approximation. Verified: swift test (424 tests, 67 suites) and the Debug app build both pass. scripts/check.sh as a whole does not pass on this machine — it aborts in the XCUITest suite with the "harness window was not presented" failures that predate all of this work. Co-authored-by: Alex Kroman <alex@assemblyai.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What & why
Adds
AppKindPrimingto recognize the frontmost application's kind (terminal, code editor, Slack, Obsidian) from its bundle identifier and inject destination-specific guidance into transcription prompts. For example, dictating into a terminal now primes the model with "You are dictating into a terminal: expect shell commands, program names, flags, and file paths."This improves recognition accuracy by telling the model what shape of text to expect — shell commands vs. code identifiers vs. casual chat — which the app's display name alone doesn't convey. For code editors, the guidance is further refined by inferring the programming language from the open file's extension in the window title (e.g., "You are writing Python in a code editor…").
Recognition keys on bundle IDs (stable app identity) rather than display names (localized, user-editable). Unrecognized apps contribute no clause, falling back to the existing destination sentence.
How it was tested
scripts/check.shpasses (or CI will, if I'm not on a Mac)Test coverage:
AppKindPrimingTests.swiftwith 14 test cases covering bundle ID recognition (exact matches and prefix families), language inference from window titles, and clause rendering for all four app kindsTranscriptionPromptTests.swiftwith 4 new cases validating prompt integration: recognized bundle IDs, bundle ID alone, language-specific guidance, and unrecognized appsTranscriptionContextTests.swiftto verify bundle ID presence makes context non-empty and produces a prompthttps://claude.ai/code/session_01R28wj6vXZDW3iS7FWpHT1U