Cleanup was the one step with only one place to run. Speech to text has three
providers behind a setting and the agent has three behind another, but the
model that drops the "eee"s out of a sentence was always a request to
OpenRouter, which meant a second key on a machine that already pays for a model
and already hands whole dictations to it as commands. Claude Code and Codex can
rewrite a sentence as easily as they can put something in your calendar, and
now they may.
cleanup.py is where that choice lives, so worker, the file transcriber and the
meeting all ask the same question rather than each building the same OpenRouter
request. What comes out of a CLI that failed is a CleanupError, which is an
ApiError, because to the chain a cleanup that failed is a cleanup that failed
however it was run: the raw transcript is still pasted and the reason still
shows in the corner, unchanged.
Neither CLI is given anything it does not need for the job. No tools, no MCP
servers, no session to resume, and the home directory rather than wherever the
agent is pointed, since a project's instructions have opinions about how text
should be written and none of them are about this transcript. The transcript
goes in fenced the same way the OpenRouter call fences it, because it is
material rather than an instruction however much of it reads like one. Claude
takes the cleanup rules as its whole system prompt; Codex has no system prompt
of its own, so they ride in front of the text, and its answer is read from the
file it writes on the way out rather than from a stdout that also carries a
header, its thinking and a token count.
The cost is seconds. OpenRouter answers in about one, a CLI in six or seven,
because each one opens a whole session to do it. That is the trade the box
says out loud, and the default has not moved: OpenRouter cleans up until you
say otherwise.
Codex's two lowest thinking levels now ask for "low". "minimal" was its bottom
rung until the newer models replaced it with "none", and each of them answers
the other's word with a 400, which the agent has been quietly hitting too.
In the settings window the model box belongs to whoever is chosen rather than
meaning three different things in turn, since an OpenRouter id and a Claude
alias do not belong in the same field, and under it is the same "found it or
not" line the agent tab has. dikte doctor asks about the program instead of the
key when a CLI does the cleaning, and the history records which model actually
did it.
Stopping a recording is the step there is no taking back. It is what sends the
audio off, and a moment later the sentence you did not mean to dictate is in
the clipboard and pasted into whatever window you were typing in. The tray menu
was the only way out, and by the time it is open the recording has already
gone. Discarding needed to be as quick as starting, which means a key.
Ctrl+Alt+Space rather than Escape: the combination the recording started with,
one modifier along, so the two are one gesture with a modifier between them.
Escape is the obvious choice and the wrong one, because it belongs to whichever
window has focus, and while you are talking something else usually has it. It
works on a dictation and on a command for the agent alike, since the one you
want to take back is the one that is running.
That made four global shortcuts, and four is the number at which three copies
of the same forty lines stop being a coincidence. The command line already had
a table of them; hotkey.py now holds it, and the settings window reads it too,
so a shortcut is a row rather than a combination box, two buttons, a status
label and three methods written out again. The window no longer takes the
commands to run as arguments either, because ipc.command_for already knows them
from the verb. Adding the fourth key is what this buys: one line in the table
and one call per row.
Both places a key can live are told about it. The listener catching the press
and the KDE shortcut arriving behind it go through the same echo guard the
toggle already had, so the two are one discard rather than two, and the tray
calls the inner method as it already did for the toggle. Ctrl+Space and
Ctrl+Alt+Space land on the same evdev key code, so there is a test for the
modifier matching that keeps them apart.
install.sh takes the second key as a second argument and refuses to register
two of the same. It hands both to `dikte shortcut install` rather than writing
kglobalshortcutsrc itself, which is what makes it work on GNOME, and what
finally puts the chosen key in the settings as well: the built-in listener
reads it from there, so a key written to only one of the two places was a key
that half worked.
Left empty in the settings window the discard key stays empty, unlike the
dictation shortcut which falls back to Ctrl+Space: a recording can always be
thrown away from the tray menu, so there is nothing to guarantee here.
Reworked on top of master. The Groq request itself needed nothing beyond a key,
a base URL and a model id, but two providers fit in an if and an else where
three do not, and each one was written out four times: in transcribe_target(),
in the key rows of the settings window, in save and in load.
config.TRANSCRIBERS holds them now, one row each: the name the user sees and
the three settings that keep its key, its endpoint and its model. The variable
an empty key falls back to is the name of its setting, shouted. The provider
box, save, load, `dikte models --provider` and `dikte test-key` all read that
table, so a fourth provider is a row and a default rather than a branch in five
files. The key field, its Test button and its answer line are built once and
the three signals became one that carries which key was asked about.
The tests moved into the files of the modules they cover and check what a
provider actually changes: that the request goes to api.groq.com with the right
model and fields, that the glossary now reaches everything except OpenRouter,
that a Groq error says Groq, and that the settings window carries the key and
the model there and back. GROQ_API_KEY is cleared for the test run like the
other two, so a developer who has one does not send a test to the network.
Co-authored-by: Muzaffer Emre <[email protected]>
install_shortcut(), remove_shortcut(), shortcut_status() and desktop_name()
were added and nothing called them: the settings window and the command line
both still went straight to the KDE ones. On GNOME, pressing Install ran
kwriteconfig6, which is not there, and the three interface strings written for
this never appeared. README said the settings window installs a GNOME or KDE
shortcut, and it installed neither.
Both callers go through the chooser now, and the status line names whichever
desktop answered.
A stem is all digits too, so "dikte meetings show 20260801" was read as a
position and found nothing: there is no twenty-millionth meeting. A number
counts back only while there are that many meetings to count back through,
and anything larger is a date somebody typed.
The socket carried a bare word and said nothing back, so a script could press
the buttons but never learn what was said. A request is JSON now, it can ask to
be answered when the run ends rather than when it starts, and the reply carries
the transcript, the agent's answer, or why nothing happened. The verbs that do
not need the microphone run in the caller's own process, which is what makes
transcribing a file or reading a setting work over ssh.