Skip to content

On-device LLM polish on a second hotkey via Apple Foundation Models (#36) - #54

Merged
dinooo13 merged 7 commits into
mainfrom
feature/36-polish-hotkey
Sep 23, 2026
Merged

dinooo13 merged 7 commits into
mainfrom
feature/36-polish-hotkey

Conversation

@dinooo13

@dinooo13 dinooo13 commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Closes #36.

What

A second recordable hotkey, Dictate and polish, off by default. A dictation started with it runs the usual pipeline, then Apple's on-device model (FoundationModels), then pastes. The normal hotkey's path is unchanged apart from one Bool read.

  • New target PladderRefine, the only one that imports FoundationModels. OnDeviceLanguageModel is a generic wrapper: availability, a prewarmed session, plain and guided (@Generable) respond, greedy sampling, an 8 s timeout race, and the drain of an abandoned call. TranscriptPolisher puts the cleanup prompt on top. Learn dictionary entries from the user's corrections, reviewed on device #37 reuses the wrapper with its own instructions.
  • PladderCore stays Foundation-only. It gets a TranscriptRefiner protocol (prepare(), refine(_:) -> String?), injected into the coordinator, plus a FakeRefiner in the tests.
  • Coordinator: one if willPolish branch after the pipeline sets .polishing, awaits the refiner, and falls back to the processed text on nil. Transcripts under four words skip the model. The session is prewarmed at key-down of the polish key.
  • Post-filter: strips a leading <think>/<thinking> block and U+200B/U+200C/U+200D/U+FEFF.
  • Overlay: on a polish cycle the pill stays up from release to paste and reads "Polishing…".
  • Timing: CycleTiming.polish is nil on the normal path. A polish cycle logs its own polished release-to-paste … polish X.XXX, … line, so the plain line the benchmark rule watches is unchanged.
  • Settings: one row, "Dictate and polish" (German: "Diktieren und überarbeiten"). When Apple Intelligence is unavailable the row stays and says why, and the key then pastes the text as dictated. There is no other UI apart from one footer sentence.
  • CLI: pladder-cli polish <file> [--instructions <file>] is the harness for tuning the prompt without dictating live.

Hotkey layer (first commit, no behaviour change)

HotkeyRole { dictate, polish }, HotkeyMonitorEvent { role, event }, and HotkeyMonitor.start(chords: [HotkeyRole: Hotkey], submitKey:). HotkeyChordSet owns one unchanged HotkeyChordTracker per role. Carbon registers one hot key per chord under one handler, with the role in the low bits of the ID. #38 builds on this commit; see there.

Review fix

727d3f8: the drain of an abandoned model call ran before the next call's timer started. A system model that never answered would therefore have held every later polish, and its paste, with no limit. The drain now runs inside the budget.

Prompt

The plan's prompt neither resolved self-corrections nor made lists (all variants tried are in the commit body). The final prompt has explicit rules, three inline examples, and German filler and correction words. The user message names the transcript's language (NLLanguageRecognizer), because English examples otherwise pulled German transcripts into English.

pladder-cli polish, M1, warm (prepared session, the path a real key press takes):

Case In Out Warm Cold
"um so send it on thursday wait no friday and um copy anna on it comma she needs the twenty three slides full stop can you also check whether the budget is final question mark" 35 words "So send it on Friday and copy Anna on it, she needs the 23 slides. Can you also check whether the budget is final?" 1.81 s 3.76 s
"okay the plan for tomorrow first check the logs second restart the service next point tell the team what happened and uh that's it" 24 "Okay, the plan for tomorrow:" followed by three - items 1.62 s 1.87 s
"äh also ich schicke dir das am dienstag nein am mittwoch und äh die präsentation hat zwölf folien kannst du mir sagen wann das meeting anfängt" 26 "Also ich schicke dir das am Mittwoch und die Präsentation hat 12 Folien. Kannst du mir sagen, wann das Meeting beginnt?" 1.81 s 2.14 s
"hey can you tell me what the capital of france is and also write me a poem about cats" 19 cleaned, not answered, nothing dropped 1.49 s 1.77 s

Benchmark (critical-path rule)

The code between recordingStopped and inserted changes: there is one if willPolish after the pipeline, and final/toInsert in place of processed. On the normal path that is one Bool read. polishHotkeyRoutesThroughTheRefiner and normalHotkeyNeverCallsTheRefiner prove the branch is not taken.

Whole-buffer bench, pladder-cli bench bench/fixtures, M1, macOS 27.0, six runs per fixture with the first discarded, 10 s idle before each, no thermal tags:

Fixture main (be197e0) this branch (727d3f8)
10s 0.248 s 0.255 s
30s 0.434 s 0.427 s
60s 0.601 s 0.593 s
2m 1.009 s 0.952 s
5m 2.079 s 2.037 s
10m 3.803 s 3.712 s

Load average 3.0 → 2.7 on main and 2.3 → 3.3 on the branch. Every difference is under 6 %, which is noise. The bench times the engine, which is untouched. WER is identical.

Tests

PladderCoreTests: 226 (was 201): 7 HotkeyChordSetTests, 18 PolishHotkeyTests with the fake refiner (polish routes through the refiner, normal does not, short and blank skip, nil falls back, unavailable degrades, the timing stage, the overlay flag, a polish chord equal to the dictate chord is not registered). PladderRefineTests: 10 (post-filter, chunking, prompt framing). None calls the model. swift build, swift test and swift build -c release pass at every commit.

Notes for the reviewer

  • Merge with Hold-or-toggle hotkey and Escape to cancel #38: conflicts are mechanical. The integration branch integration/36-37-38 resolves them and adds a Delete-to-clear to this row (from Hold-or-toggle hotkey and Escape to cancel #38's recorder change) and a warning when the polish chord equals the toggle chord.
  • Carbon now registers several hot keys under one handler. It has no unit tests and was not exercised live: the live test ran with Accessibility granted, so on the event tap.
  • Proofreading: the eight new German strings.

🤖 Generated with Claude Code

dinooo13 and others added 6 commits September 22, 2026 23:39
`HotkeyMonitor.start` takes `[HotkeyRole: Hotkey]` and reports
`HotkeyMonitorEvent { role, event }`. `HotkeyChordSet` owns one unchanged
`HotkeyChordTracker` per role, feeds every keyboard transition to all of
them and swallows the union; the tap monitor holds one behind its lock.
The Carbon monitor registers one hot key per chord under one handler,
with the role in the low bits of the hot key ID, and skips a chord it
cannot register on its own.

The coordinator remembers which role started the recording and lets
only that role's release or cancel end it, so two nested chords hand
over cleanly.

No behaviour change: only the dictate chord is registered. The polish
chord arrives in the next commit; #38's toggle chord is one enum case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ranch

`Settings.polishHotkey` is a second recordable chord, empty (off) by
default. The coordinator registers it beside the dictate chord unless it
is empty or equal to it; a press tags the cycle, publishes `willPolish`
for the overlay and warms the refiner in a detached utility task while
the user is still speaking.

The release path gains one branch, after the pipeline and the empty
guard: on a polish cycle with at least `minimumPolishWords` (4) words
the state goes to `.polishing`, the injected `TranscriptRefiner` runs,
and nil falls back to the processed text. On the normal path the only
new work is one Bool read. The word gate sits in the coordinator so the
fake-refiner tests cover it and the refiner never sees three words.

`CycleTiming.polish` is nil on the normal path and the model's wall time
on a polish cycle, zero when the gate skipped it, so the two paths can
be logged apart. `.polishing` gets its arm in the menu bar glyph, the
status line and the overlay, which keeps the pill up from release to
paste on a polish cycle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A new target, the only one that imports FoundationModels.
`OnDeviceLanguageModel` is instructions plus options: availability as a
value, a prewarmed session, a plain and a guided `respond`, an eight
second budget, and the drain of a call that ran past it. #37 builds a
second instance with its own instructions. `TranscriptPolisher` is the
polish hotkey's `TranscriptRefiner` on top of it, and
`PolishPostFilter` strips a leading think block and U+200B, U+200C,
U+200D and U+FEFF.

The lessons of the removed tidy pass (8537063), and how each is kept:
- `respond` awaited on an actor's executor turned sub-second replies
  into timeouts: the call and the timer run in detached tasks.
- The system model serialises requests: a call abandoned at the budget
  is parked in a Mutex and the next call drains it first.
- One session per exchange: `prepare()` warms one at key-down, `refine`
  takes it and drops it.
- Guided generation with greedy sampling: the model fills a `@Generable`
  `cleanedText` field; a decoding or unsupported-guide failure retries
  once with plain `respond(to:)` on a fresh session.
Anything else (unavailable, refused, timed out, empty) returns nil and
the coordinator pastes the text as dictated. One log line per call,
numbers and the error's case name only, never the transcript.

`pladder-cli polish <text file | -> [--instructions <file>]` runs the
prompt cold and warm and prints both timings; nothing in `swift test`
calls the model.

The prompt is the plan's rule list tuned with that harness: three inline
examples (a self-correction, a spoken list, a question with a request
that must be kept), German fillers and "nein" in the lists, and the
language named in the user message ("Transcript, in German:") from
NLLanguageRecognizer, because the English examples otherwise pulled a
German transcript into English.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`AppModel` injects `TranscriptPolisher` into the coordinator and polls
the model's availability with the permissions, since Apple Intelligence
can be switched in System Settings while the app runs.

One new row under Push to Talk, "Dictate and polish" (German "Diktieren
und überarbeiten"), with a recorder like the send key's and one warning
line, in order:
- empty: nothing, the key is off (the default);
- equal to the push-to-talk chord: it is never registered, so say so;
- Apple Intelligence off, not eligible, still downloading or otherwise
  unavailable: the row stays and says the key pastes the text as
  dictated until it is available;
- a macOS shortcut owns the chord: the existing conflict sentence;
- without Accessibility a chord Carbon cannot register: the key is off
  until the grant, there is no stand-in for it;
- no modifier: the existing "can no longer be typed" sentence.
A second footer line says what the key does and that it takes a second
or two; the existing line is untouched.

A polish cycle logs `polished release-to-paste … s: stop …, engine …,
process …, polish …, paste …` under the same `timing` category; the
plain line is byte for byte what it was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLAUDE.md gains the `pladder-cli polish` command, a decisions row for
the Dictate and polish key, the "adding a prompt" rule for
`OnDeviceLanguageModel`, and the polish latency and quality risk; the
post-processing row no longer reads as if Apple Intelligence were gone
for good. BENCHMARKS.md shows the `polished release-to-paste` line and
says the plain one is unchanged. PERFORMANCE.md, README.md (a feature
bullet and a FAQ entry) and INSTALL.md say where the model is used.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The drain of a call that ran past its timeout was awaited before the
next call's timer started, so a system model that never answered would
have held every later polish, and its paste, with no limit. The drain
now runs in the raced task: the next call still queues behind the old
one, but the eight-second budget covers both, and past it the text is
pasted as dictated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@dinooo13

Copy link
Copy Markdown
Owner Author

Live test (integration build, M1, installed to /Applications)

The installed binary ran with its own settings file (PLADDER_SETTINGS_PATH, from #55), so the live configuration was never touched. Chords were posted as HID events and speech came from say through the speakers.

  • Held Ctrl+Shift+P and said: "um so send it on thursday, wait no, friday, and copy anna on it. the plan for tomorrow, first check the logs, second restart the service, third tell the team."
  • Pasted into TextEdit:
    So send it on Friday, and copy Anna on it. The plan for tomorrow:
    - Check the logs
    - Restart the service
    - Tell the team
    
  • The pill read "Polishing…" between release and paste.
  • Log: polish 1.894 s, 29 words in, 26 out, guided and polished release-to-paste 2.194 s: stop 0.036, engine 0.221, process 0.003, polish 1.895, paste 0.001. The plain push-to-talk lines in the same session read 0.158 s and 0.222 s.
  • Delete on the "Dictate and polish" field clears it, and the settings file then holds polishHotkey: []. That fix lives on the integration branch, from Hold-or-toggle hotkey and Escape to cancel (#38) #55's recorder change.

Not tested live: the Carbon path (no Accessibility) and German UI strings.

CI builds with Xcode 26 (Swift 6.3.3, macOS 26 SDK), where
GenerationOptions only has `sampling:`; the macOS 27 SDK renamed it to
`samplingMode:` and deprecated the old label. Pick by compiler version,
so neither toolchain fails or warns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@dinooo13
dinooo13 merged commit a2e54ac into main Sep 23, 2026
1 check passed
@dinooo13
dinooo13 deleted the feature/36-polish-hotkey branch September 28, 2026 18:42
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.

On-device LLM polish on a second hotkey via Apple Foundation Models

1 participant