diff --git a/README.md b/README.md
index 4b42583..0dd4540 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Dikte
Press `Ctrl+Space`, talk, press again. The recording goes to OpenAI or OpenRouter
-for transcription, a model on OpenRouter cleans it up (dropping the *uh*s, the
+for transcription, a model cleans it up (dropping the *uh*s, the
restarts, the missing punctuation), and the result lands in your clipboard and
is pasted into whatever window you were typing in.
@@ -43,8 +43,8 @@ again and leaves your settings and dictations alone unless you pass `--purge`.
Three keys go in the settings window: **OpenAI**, **Groq** and **OpenRouter**.
Speech to text runs on any of them (`gpt-4o-transcribe` by default), cleanup
-always on OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key
-can cover both. They fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and
+on OpenRouter (`google/gemini-3.5-flash-lite`) or, when either is installed, on
+Claude Code or Codex instead, so a single OpenRouter key can cover both. They fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and
`OPENROUTER_API_KEY`, and are
stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off,
in which case the raw transcript is pasted, and a thinking model's effort can be
@@ -148,6 +148,7 @@ audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
meeting.py channel split, speaker labelling, cleanup, minutes
assistant.py running a dictation through Claude Code, Codex or OpenRouter
api.py transcription on either provider, OpenRouter cleanup (stdlib only)
+cleanup.py who rewrites the transcript: OpenRouter, Claude Code or Codex
worker.py transcribe → clean up → clipboard → paste
vad.py deciding whether a recording holds speech at all
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
diff --git a/README.tr.md b/README.tr.md
index 97b007d..29f2f95 100644
--- a/README.tr.md
+++ b/README.tr.md
@@ -1,7 +1,7 @@
# Dikte
`Ctrl+Space`'e bas, konuş, tekrar bas. Ses OpenAI'ye ya da OpenRouter'a gidip
-yazıya çevrilir, OpenRouter'daki bir model transkripti temizler (ıı'lar,
+yazıya çevrilir, bir model transkripti temizler (ıı'lar,
tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye
yapıştırılır.
@@ -44,8 +44,8 @@ diktelerine dokunmaz.
Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**.
Sesi yazıya çevirme üçünden birinde çalışır (varsayılan `gpt-4o-transcribe`),
-temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir
-OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY`,
+temizleme OpenRouter'da (`google/gemini-3.5-flash-lite`) ya da kuruluysa Claude
+Code veya Codex'te, yani tek bir OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY`,
`GROQ_API_KEY` ve `OPENROUTER_API_KEY` kullanılır;
anahtarlar `~/.config/dikte/config.json`
içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript
@@ -147,6 +147,7 @@ audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak
assistant.py dikteyi Claude Code, Codex ya da OpenRouter'dan geçirme
api.py iki sağlayıcıda transkript + OpenRouter temizleme (yalnız stdlib)
+cleanup.py transkripti kim temizler: OpenRouter, Claude Code ya da Codex
worker.py transkript → temizleme → pano → yapıştırma
vad.py kayıtta gerçekten konuşma var mı kararı
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
diff --git a/assistant.py b/assistant.py
index 20339bb..f708ab3 100644
--- a/assistant.py
+++ b/assistant.py
@@ -75,7 +75,10 @@ CODEX_ITEMS = {
CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low",
"medium": "medium", "high": "high", "xhigh": "xhigh",
"max": "max"}
-CODEX_EFFORT = {"none": "minimal", "minimal": "minimal", "low": "low",
+# "minimal" was Codex's bottom rung until the newer models replaced it with
+# "none", and each of them rejects the other's word for it with a 400. "low" is
+# the one every model has, so the two lowest rungs land there instead.
+CODEX_EFFORT = {"none": "low", "minimal": "low", "low": "low",
"medium": "medium", "high": "high", "xhigh": "high",
"max": "high"}
@@ -421,7 +424,7 @@ def _conclude(found, code, stderr, session, service):
if code != 0 and not found["answer"]:
if session and _session_missing(stderr):
raise _SessionGone()
- raise AssistantError(_last_line(stderr) or found["failure"] or t(
+ raise AssistantError(last_line(stderr) or found["failure"] or t(
"{service} exited with code {code}.", service=service, code=code))
if found["failure"] and not found["answer"]:
raise AssistantError(found["failure"])
@@ -480,6 +483,11 @@ def _finish(proc):
return stderr
-def _last_line(text):
+def last_line(text):
+ """The line worth showing out of a CLI's stderr: the last one it wrote.
+
+ Shared with cleanup, which runs the same two programs for a different job
+ and fails the same way when they are unhappy.
+ """
lines = [line for line in (text or "").splitlines() if line.strip()]
return lines[-1].strip() if lines else ""
diff --git a/cleanup.py b/cleanup.py
new file mode 100644
index 0000000..f937756
--- /dev/null
+++ b/cleanup.py
@@ -0,0 +1,181 @@
+"""Who rewrites the transcript once it has been heard.
+
+Normally a small model on OpenRouter: one request, a second, a few tenths of a
+cent. A machine with Claude Code or Codex on it is already paying for a model
+though, and the subscription that answers "put that in my calendar on Thursday"
+can just as well take the "eee"s out of a sentence. No second key, no second
+bill. It costs seconds rather than one, because a CLI opens a whole session to
+do it, which is the trade.
+
+Whoever does it, the job is the same one: no tools, no files, no memory of the
+last dictation. There is nothing here to look up and nothing to carry over, and
+a transcript is text from a microphone rather than an instruction, so the less
+the agent can reach while it reads one, the better.
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+
+import api
+import assistant
+from i18n import t
+
+PROVIDERS = ("openrouter", "claude", "codex")
+
+
+class CleanupError(api.ApiError):
+ """What a CLI could not do.
+
+ An ApiError because to the chain a cleanup that failed is a cleanup that
+ failed, whichever way it was run, and every caller already catches one and
+ keeps the raw transcript.
+ """
+
+
+def provider(conf):
+ chosen = conf["cleanup_provider"]
+ return chosen if chosen in PROVIDERS else "openrouter"
+
+
+def executable(name):
+ """The CLI a provider runs, or "" when it needs none."""
+ return {"claude": "claude", "codex": "codex"}.get(name, "")
+
+
+def model(conf):
+ """Which model does the cleaning, for the history and the settings window."""
+ name = provider(conf)
+ if name == "claude":
+ return conf["cleanup_claude_model"].strip() or "haiku"
+ if name == "codex":
+ # Codex is left on whatever it is set to unless a model is typed in, so
+ # here there is only the name of the thing that did it.
+ return conf["cleanup_codex_model"].strip() or "codex"
+ return conf["cleanup_model"]
+
+
+def run(text, conf, system_prompt, timeout=180):
+ """Hand the transcript to whoever is set to clean it up."""
+ name = provider(conf)
+ if name == "openrouter":
+ return api.cleanup(
+ text, conf.openrouter_key(), conf["cleanup_model"], system_prompt,
+ reasoning=conf["cleanup_reasoning"],
+ base_url=conf["openrouter_base_url"], timeout=timeout,
+ )
+ runner = _claude if name == "claude" else _codex
+ return runner(text, conf, system_prompt, timeout)
+
+
+def _wrap(text):
+ """The same fence the OpenRouter call puts around it: this is the material,
+ not the instruction, however much of it reads like one."""
+ return f"\n{text}\n"
+
+
+# --- Claude Code ----------------------------------------------------------
+
+def _claude(text, conf, system_prompt, timeout):
+ cmd = [
+ "claude", "-p", _wrap(text),
+ # --system-prompt rather than --append-system-prompt: the cleanup rules
+ # are the whole job, and Claude Code's own instructions are about
+ # working on a codebase.
+ "--system-prompt", system_prompt,
+ "--model", model(conf),
+ "--output-format", "text",
+ "--tools", "", # nothing to run
+ "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
+ "--no-session-persistence", # nothing to resume
+ ]
+ effort = assistant.CLAUDE_EFFORT.get(conf["cleanup_reasoning"], "")
+ if effort:
+ cmd += ["--effort", effort]
+
+ answer = _output(cmd, timeout, "Claude")
+ if not answer:
+ raise CleanupError(t("{service} answered with nothing.", service="Claude"))
+ return answer
+
+
+# --- Codex ----------------------------------------------------------------
+
+def _codex(text, conf, system_prompt, timeout):
+ # Codex takes no system prompt of its own, so the rules ride in front of the
+ # transcript, kept apart from it so the two are not read as one.
+ body = f"{system_prompt}\n\n---\n\n{_wrap(text)}"
+ cmd = [
+ "codex", "exec",
+ "--sandbox", "read-only", # it has no reason to touch the disk
+ "--skip-git-repo-check",
+ "--ephemeral", # nothing to resume
+ "--color", "never",
+ "-c", 'approval_policy="never"', # there is nobody here to approve
+ ]
+ if conf["cleanup_codex_model"].strip():
+ cmd += ["-m", conf["cleanup_codex_model"].strip()]
+ effort = assistant.CODEX_EFFORT.get(conf["cleanup_reasoning"], "")
+ if effort:
+ cmd += ["-c", f'model_reasoning_effort="{effort}"']
+
+ # `codex exec` prints a header, its thinking and a token count around the
+ # answer; the file it writes on the way out is the answer on its own.
+ handle, last_message = tempfile.mkstemp(prefix="dikte-cleanup-", suffix=".txt")
+ os.close(handle)
+ cmd += ["-o", last_message, body]
+ try:
+ _output(cmd, timeout, "Codex")
+ answer = _read(last_message)
+ finally:
+ try:
+ os.unlink(last_message)
+ except OSError:
+ pass
+
+ if not answer:
+ raise CleanupError(t("{service} answered with nothing.", service="Codex"))
+ return answer
+
+
+def _read(path):
+ try:
+ with open(path, encoding="utf-8", errors="replace") as fh:
+ return fh.read().strip()
+ except OSError:
+ return ""
+
+
+# --- running a CLI --------------------------------------------------------
+
+def _output(cmd, timeout, service):
+ """Run cmd to the end and return what it printed.
+
+ It runs in the home directory rather than wherever the agent is pointed: a
+ project's instructions have opinions about how text should be written, and
+ none of them are about this transcript.
+ """
+ binary = cmd[0]
+ if not shutil.which(binary):
+ raise CleanupError(t(
+ "{binary} not found. Install it, or have OpenRouter clean up "
+ "instead, under Settings → API and models.", binary=binary,
+ ))
+ try:
+ done = subprocess.run(
+ cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
+ timeout=timeout,
+ )
+ except subprocess.TimeoutExpired:
+ raise CleanupError(t("{service} did not finish within {seconds} seconds.",
+ service=service, seconds=timeout)) from None
+ except OSError as exc:
+ raise CleanupError(t("Could not run {binary}: {error}",
+ binary=binary, error=exc)) from exc
+ if done.returncode != 0:
+ raise CleanupError(assistant.last_line(done.stderr) or t(
+ "{service} exited with code {code}.",
+ service=service, code=done.returncode))
+ return (done.stdout or "").strip()
diff --git a/cli.py b/cli.py
index d907537..334a957 100644
--- a/cli.py
+++ b/cli.py
@@ -25,6 +25,7 @@ from PyQt6.QtCore import QCoreApplication, QTimer
import api
import assistant
import audio
+import cleanup
import config as cfg
import filetranscribe
import hotkey
@@ -764,16 +765,18 @@ def cmd_status(opts):
def cmd_doctor(opts):
"""What the settings window checks behind its buttons, in one pass."""
conf = cfg.Config()
- programs = {name: shutil.which(name) or ""
- for name in ("pw-record", "wl-copy", "ydotool", "ffmpeg",
- "pactl", "kwriteconfig6",
- assistant.executable(assistant.provider(conf)) or "claude")}
+ wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6",
+ assistant.executable(assistant.provider(conf)) or "claude",
+ cleanup.executable(cleanup.provider(conf))]
+ programs = {name: shutil.which(name) or "" for name in wanted if name}
target = conf.transcribe_target()
+ cleaner = cleanup.provider(conf)
checks = {
"programs": programs,
"transcription": {"provider": target.provider, "model": target.model,
"key": bool(target.api_key)},
- "cleanup": {"enabled": conf["cleanup_enabled"], "model": conf["cleanup_model"],
+ "cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner,
+ "model": cleanup.model(conf),
"key": bool(conf.openrouter_key())},
"agent": {"provider": assistant.provider(conf),
"directory": assistant.working_dir(conf)},
@@ -784,8 +787,11 @@ def cmd_doctor(opts):
lines += [
f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on "
f"{target.model}",
- f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on "
- f"{conf['cleanup_model']}",
+ # Cleanup on a CLI needs no key, so what is checked is the program.
+ (f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on "
+ f"{conf['cleanup_model']}") if cleaner == "openrouter" else
+ (f"{'✓' if programs[cleanup.executable(cleaner)] else '✗'} "
+ f"{cleanup.executable(cleaner)}, cleaning up on {cleanup.model(conf)}"),
f"{'✓' if checks['running'] else '·'} application "
+ ("running" if checks["running"] else "not running"),
]
diff --git a/config.py b/config.py
index 6d052b0..6476bad 100644
--- a/config.py
+++ b/config.py
@@ -375,7 +375,10 @@ DEFAULTS = {
"language": "tr",
"transcribe_prompt": "",
"cleanup_enabled": True,
+ "cleanup_provider": "openrouter", # openrouter | claude | codex
"cleanup_model": "google/gemini-3.5-flash-lite",
+ "cleanup_claude_model": "haiku", # Claude Code: an alias, or a full model id
+ "cleanup_codex_model": "", # empty -> whatever Codex is set to
"cleanup_reasoning": "", # empty -> whatever the model does by default
"cleanup_prompt": "", # empty -> language-specific default
"auto_paste": True,
diff --git a/filetranscribe.py b/filetranscribe.py
index 4839d13..f797d58 100644
--- a/filetranscribe.py
+++ b/filetranscribe.py
@@ -17,6 +17,7 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal
import api
+import cleanup
from i18n import t
CHUNK_SECONDS = 600 # 10 min ≈ 19 MB at 16 kHz mono s16
@@ -130,14 +131,7 @@ class FileTranscriber(QObject):
out = []
for block in split_text(text, timestamps):
self._check()
- out.append(api.cleanup(
- block,
- conf.openrouter_key(),
- conf["cleanup_model"],
- prompt,
- reasoning=conf["cleanup_reasoning"],
- base_url=conf["openrouter_base_url"],
- ))
+ out.append(cleanup.run(block, conf, prompt))
return ("\n" if timestamps else "\n\n").join(out)
diff --git a/i18n.py b/i18n.py
index 7466143..c414e34 100644
--- a/i18n.py
+++ b/i18n.py
@@ -186,6 +186,18 @@ TR = {
"Connection works. {count} audio models visible.":
"Bağlantı tamam. {count} ses modeli görünüyor.",
"Clean the transcript with a model": "Transkripti bir modelle temizle",
+ "OpenRouter is the quickest and the only one that needs nothing installed. "
+ "Claude Code and Codex clean up on the subscription you already have, "
+ "without a second key, and take a few seconds longer because each one opens "
+ "a session to do it.":
+ "En hızlısı OpenRouter'dır ve kurulu bir program istemeyen tek seçenektir. "
+ "Claude Code ile Codex, temizliği hâlihazırda ödediğin abonelik üzerinden "
+ "yapar, ikinci bir anahtar istemez; her biri bunun için bir oturum açtığından "
+ "birkaç saniye daha uzun sürer.",
+ "{binary} is not on your PATH, so cleanup would fail and the raw transcript "
+ "would be pasted. Install it, or pick another one above.":
+ "{binary} PATH'te değil; temizleme başarısız olur ve ham transkript "
+ "yapıştırılır. Kur ya da yukarıdan başka birini seç.",
"Thinking": "Düşünme",
"Model's own default": "Modelin kendi varsayılanı",
"Off": "Kapalı",
@@ -380,6 +392,14 @@ TR = {
"It was not allowed to use: {tools}": "Şunları kullanmasına izin yoktu: {tools}",
"The model returned an empty reply.": "Model boş cevap döndürdü.",
+ # --- cleanup, when a CLI does it ----------------------------------------
+ "{binary} not found. Install it, or have OpenRouter clean up instead, "
+ "under Settings → API and models.":
+ "{binary} bulunamadı. Kur ya da Ayarlar → API ve modeller sekmesinden "
+ "temizliği OpenRouter'a bırak.",
+ "{service} did not finish within {seconds} seconds.":
+ "{service} {seconds} saniye içinde bitmedi.",
+
# --- settings: the agent ------------------------------------------------
"Agent": "Ajan",
"This shortcut records the same way dictation does, but the transcript is "
diff --git a/meeting.py b/meeting.py
index c0713c6..c178668 100644
--- a/meeting.py
+++ b/meeting.py
@@ -25,6 +25,7 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal
import api
+import cleanup
import config as cfg
import filetranscribe
import vad
@@ -208,14 +209,7 @@ class MeetingPipeline(QObject):
if len(blocks) > 1:
self._say(t("Cleaning up {index}/{count}…",
index=index, count=len(blocks)))
- out.append(api.cleanup(
- block,
- conf.openrouter_key(),
- conf["cleanup_model"],
- prompt,
- reasoning=conf["cleanup_reasoning"],
- base_url=conf["openrouter_base_url"],
- ))
+ out.append(cleanup.run(block, conf, prompt, timeout=600))
return "\n".join(out)
def _write(self, doc_path, minutes, transcript, entry):
diff --git a/settings_ui.py b/settings_ui.py
index 3a36c7c..c3002f9 100644
--- a/settings_ui.py
+++ b/settings_ui.py
@@ -16,6 +16,7 @@ from PyQt6.QtWidgets import (
import api
import assistant
import audio
+import cleanup
import config as cfg
import filetranscribe
import hotkey
@@ -49,6 +50,15 @@ CLEANUP_MODELS = [
"google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5",
"openai/gpt-5-mini", "meta-llama/llama-3.3-70b-instruct",
]
+# The same two CLIs the agent can run on, doing the smaller job instead. They
+# are offered second: a request to OpenRouter is over in a second, and a CLI
+# opens a session first.
+CLEANUP_PROVIDERS = [
+ ("OpenRouter", "openrouter"), ("Claude Code", "claude"), ("Codex", "codex"),
+]
+# Cleaning up a sentence is the lightest thing either of them will ever be
+# asked, so the small model comes first.
+CLEANUP_CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"]
# Minutes are a harder job than cleanup: an hour of talk has to be read whole
# and turned into decisions, so the starting points are the larger models.
MEETING_MODELS = [
@@ -282,16 +292,42 @@ class SettingsWindow(QDialog):
outer.addWidget(stt)
orr = QGroupBox(t("Transcript cleanup"))
- orr_form = QFormLayout(orr)
+ orr_form = self.cleanup_form = QFormLayout(orr)
self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model"))
orr_form.addRow("", self.cleanup_enabled)
+ self.cleanup_provider = QComboBox()
+ for label, value in CLEANUP_PROVIDERS:
+ self.cleanup_provider.addItem(t(label), value)
+ self.cleanup_provider.setToolTip(t(
+ "OpenRouter is the quickest and the only one that needs nothing "
+ "installed. Claude Code and Codex clean up on the subscription you "
+ "already have, without a second key, and take a few seconds longer "
+ "because each one opens a session to do it."
+ ))
+ self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed)
+ orr_form.addRow(t("Runs on"), self.cleanup_provider)
+
self.cleanup_model = QComboBox()
self.cleanup_model.setEditable(True)
self.cleanup_model.addItems(CLEANUP_MODELS)
self.refresh_models = QPushButton(t("Fetch model list"))
self.refresh_models.clicked.connect(self._load_models)
- orr_form.addRow(t("Model"), self._row(self.cleanup_model, self.refresh_models))
+ self.cleanup_model_row = self._row(self.cleanup_model, self.refresh_models)
+ orr_form.addRow(t("Model"), self.cleanup_model_row)
+
+ # One row per provider rather than one box that means a different thing
+ # in each: an OpenRouter id and a Claude alias do not belong in the same
+ # field, and only the row of whoever is chosen is on screen.
+ self.cleanup_claude_model = QComboBox()
+ self.cleanup_claude_model.setEditable(True)
+ self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS)
+ orr_form.addRow(t("Model"), self.cleanup_claude_model)
+
+ self.cleanup_codex_model = QComboBox()
+ self.cleanup_codex_model.setEditable(True)
+ self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS)
+ orr_form.addRow(t("Model"), self.cleanup_codex_model)
self.cleanup_reasoning = QComboBox()
for label, value in REASONING_LEVELS:
@@ -951,6 +987,12 @@ class SettingsWindow(QDialog):
self._provider_changed() # selecting index 0 fires no signal
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
self.cleanup_model.setCurrentText(conf["cleanup_model"])
+ self.cleanup_claude_model.setCurrentText(conf["cleanup_claude_model"])
+ self.cleanup_codex_model.setCurrentText(
+ conf["cleanup_codex_model"] or t("Codex's own default")
+ )
+ self._select_data(self.cleanup_provider, conf["cleanup_provider"])
+ self._cleanup_provider_changed() # selecting index 0 fires no signal
self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"])
self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt())
self.file_cleanup_prompt.setPlainText(
@@ -1029,7 +1071,14 @@ class SettingsWindow(QDialog):
conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
+ conf["cleanup_provider"] = self.cleanup_provider.currentData() or "openrouter"
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
+ conf["cleanup_claude_model"] = (self.cleanup_claude_model.currentText().strip()
+ or cfg.DEFAULTS["cleanup_claude_model"])
+ codex_cleanup_model = self.cleanup_codex_model.currentText().strip()
+ conf["cleanup_codex_model"] = (
+ "" if codex_cleanup_model == t("Codex's own default") else codex_cleanup_model
+ )
conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or ""
# Store an empty prompt when it matches the default, so switching the
@@ -1347,6 +1396,27 @@ class SettingsWindow(QDialog):
else missing
)
+ def _cleanup_provider_changed(self):
+ provider = self.cleanup_provider.currentData() or "openrouter"
+ self.cleanup_form.setRowVisible(self.cleanup_model_row,
+ provider == "openrouter")
+ self.cleanup_form.setRowVisible(self.cleanup_claude_model,
+ provider == "claude")
+ self.cleanup_form.setRowVisible(self.cleanup_codex_model,
+ provider == "codex")
+ binary = cleanup.executable(provider)
+ found = shutil.which(binary) if binary else ""
+ if not binary:
+ self.models_label.setText(t("Runs on OpenRouter."))
+ elif found:
+ self.models_label.setText(t("Found: {path}", path=found))
+ else:
+ self.models_label.setText(t(
+ "{binary} is not on your PATH, so cleanup would fail and the raw "
+ "transcript would be pasted. Install it, or pick another one "
+ "above.", binary=binary,
+ ))
+
def _assistant_provider_changed(self):
provider = self.assistant_provider.currentData() or "claude"
self.claude_box.setVisible(provider == "claude")
diff --git a/tests/test_assistant.py b/tests/test_assistant.py
index 8fee724..c57cfbe 100644
--- a/tests/test_assistant.py
+++ b/tests/test_assistant.py
@@ -79,9 +79,12 @@ class Effort(unittest.TestCase):
self.assertEqual(assistant.CODEX_EFFORT["xhigh"], "high")
self.assertEqual(assistant.CODEX_EFFORT["max"], "high")
- def test_claude_has_no_rung_below_low(self):
- self.assertEqual(assistant.CLAUDE_EFFORT["none"], "low")
- self.assertEqual(assistant.CLAUDE_EFFORT["minimal"], "low")
+ def test_neither_one_asks_for_a_rung_below_low(self):
+ # Claude has none; Codex has one, but calls it "minimal" on the older
+ # models and "none" on the newer ones, and refuses the wrong word.
+ for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT):
+ self.assertEqual(scale["none"], "low")
+ self.assertEqual(scale["minimal"], "low")
def test_an_empty_setting_asks_for_nothing(self):
self.assertEqual(assistant.CLAUDE_EFFORT.get("", ""), "")
@@ -232,10 +235,10 @@ class SessionMissing(unittest.TestCase):
self.assertFalse(assistant._session_missing(text))
def test_the_last_line_is_the_one_worth_showing(self):
- self.assertEqual(assistant._last_line("warning\n\nreal error\n"),
+ self.assertEqual(assistant.last_line("warning\n\nreal error\n"),
"real error")
- self.assertEqual(assistant._last_line(""), "")
- self.assertEqual(assistant._last_line(None), "")
+ self.assertEqual(assistant.last_line(""), "")
+ self.assertEqual(assistant.last_line(None), "")
class Conclude(DikteTest):
diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py
new file mode 100644
index 0000000..84d56b4
--- /dev/null
+++ b/tests/test_cleanup.py
@@ -0,0 +1,211 @@
+"""Who cleans the transcript up, and what they are asked.
+
+The CLIs are faked at subprocess.run: what the tests read is the argument list
+each one is given, where the answer is picked up from, and what happens to the
+chain when the program is missing, slow or unhappy. The OpenRouter path is the
+one that was always there and is checked here only for still being taken.
+"""
+
+import os
+import subprocess
+import unittest
+from unittest import mock
+
+import api
+import cleanup
+from tests.support import DikteTest
+
+
+def fake_run(stdout="", code=0, stderr="", last_message=""):
+ """Stand in for subprocess.run, writing the file Codex would have written."""
+ calls = []
+
+ def run(cmd, **kwargs):
+ calls.append(cmd)
+ if last_message and "-o" in cmd:
+ with open(cmd[cmd.index("-o") + 1], "w", encoding="utf-8") as fh:
+ fh.write(last_message)
+ return subprocess.CompletedProcess(cmd, code, stdout, stderr)
+
+ return mock.patch.object(subprocess, "run", side_effect=run), calls
+
+
+class Provider(DikteTest):
+ def test_the_default_is_still_openrouter(self):
+ self.assertEqual(cleanup.provider(self.config()), "openrouter")
+
+ def test_a_provider_this_version_does_not_have(self):
+ self.assertEqual(
+ cleanup.provider(self.config(cleanup_provider="ollama")), "openrouter")
+
+ def test_each_one_is_recognised(self):
+ for name in cleanup.PROVIDERS:
+ with self.subTest(name=name):
+ self.assertEqual(
+ cleanup.provider(self.config(cleanup_provider=name)), name)
+
+ def test_what_each_one_runs(self):
+ self.assertEqual(cleanup.executable("claude"), "claude")
+ self.assertEqual(cleanup.executable("codex"), "codex")
+ self.assertEqual(cleanup.executable("openrouter"), "")
+
+ def test_the_model_named_in_the_history_is_the_one_that_did_it(self):
+ self.assertEqual(cleanup.model(self.config(cleanup_model="some/model")),
+ "some/model")
+ self.assertEqual(
+ cleanup.model(self.config(cleanup_provider="claude")), "haiku")
+ self.assertEqual(
+ cleanup.model(self.config(cleanup_provider="claude",
+ cleanup_claude_model="opus")), "opus")
+ # Codex on its own default has no model id to report, only a name.
+ self.assertEqual(
+ cleanup.model(self.config(cleanup_provider="codex")), "codex")
+ self.assertEqual(
+ cleanup.model(self.config(cleanup_provider="codex",
+ cleanup_codex_model="gpt-5.4")), "gpt-5.4")
+
+
+class OpenRouter(DikteTest):
+ def test_it_is_still_one_request_with_the_settings_as_they_were(self):
+ conf = self.config(openrouter_api_key="sk-or-test",
+ cleanup_model="some/model", cleanup_reasoning="low")
+ with mock.patch.object(api, "cleanup", return_value="Done.") as call:
+ self.assertEqual(cleanup.run("uh, done", conf, "the rules"), "Done.")
+ text, key, model, prompt = call.call_args.args
+ self.assertEqual((text, key, model, prompt),
+ ("uh, done", "sk-or-test", "some/model", "the rules"))
+ self.assertEqual(call.call_args.kwargs["reasoning"], "low")
+
+ def test_no_cli_is_started_for_it(self):
+ conf = self.config(openrouter_api_key="sk-or-test")
+ patcher, calls = fake_run(stdout="never")
+ with patcher, mock.patch.object(api, "cleanup", return_value="Done."):
+ cleanup.run("uh, done", conf, "the rules")
+ self.assertEqual(calls, [])
+
+
+class ClaudeCode(DikteTest):
+ def setUp(self):
+ super().setUp()
+ self.conf = self.config(cleanup_provider="claude")
+ self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
+
+ def run_cleanup(self, text="uh, book it", **kwargs):
+ patcher, calls = fake_run(**kwargs)
+ with patcher:
+ answer = cleanup.run(text, self.conf, "the rules")
+ return answer, calls[0]
+
+ def test_the_transcript_goes_in_fenced_and_the_rules_go_in_as_the_prompt(self):
+ answer, cmd = self.run_cleanup(stdout="Book it.\n")
+ self.assertEqual(answer, "Book it.")
+ self.assertEqual(cmd[0], "claude")
+ self.assertIn("\nuh, book it\n", cmd)
+ self.assertEqual(cmd[cmd.index("--system-prompt") + 1], "the rules")
+ self.assertEqual(cmd[cmd.index("--model") + 1], "haiku")
+
+ def test_it_is_given_nothing_to_run_and_nothing_to_remember(self):
+ _, cmd = self.run_cleanup(stdout="Book it.")
+ self.assertEqual(cmd[cmd.index("--tools") + 1], "")
+ self.assertIn("--strict-mcp-config", cmd)
+ self.assertIn("--no-session-persistence", cmd)
+
+ def test_the_thinking_setting_is_carried_over_in_its_own_words(self):
+ self.conf["cleanup_reasoning"] = "none"
+ _, cmd = self.run_cleanup(stdout="Book it.")
+ self.assertEqual(cmd[cmd.index("--effort") + 1], "low")
+
+ def test_no_thinking_setting_means_no_flag(self):
+ _, cmd = self.run_cleanup(stdout="Book it.")
+ self.assertNotIn("--effort", cmd)
+
+ def test_a_model_of_your_own(self):
+ self.conf["cleanup_claude_model"] = "claude-sonnet-5"
+ _, cmd = self.run_cleanup(stdout="Book it.")
+ self.assertEqual(cmd[cmd.index("--model") + 1], "claude-sonnet-5")
+
+ def test_an_answer_of_nothing_is_a_failure_rather_than_an_empty_paste(self):
+ with self.assertRaises(cleanup.CleanupError):
+ self.run_cleanup(stdout=" \n")
+
+ def test_the_last_line_of_the_complaint_is_what_gets_shown(self):
+ with self.assertRaises(cleanup.CleanupError) as caught:
+ self.run_cleanup(code=1, stderr="a warning\nout of credit\n")
+ self.assertEqual(str(caught.exception), "out of credit")
+
+ def test_a_failure_is_the_same_kind_the_chain_already_catches(self):
+ # worker, the file transcriber and the meeting all keep the raw
+ # transcript when an ApiError comes out of here.
+ self.assertTrue(issubclass(cleanup.CleanupError, api.ApiError))
+
+ def test_a_program_that_is_not_installed_says_so_before_running_anything(self):
+ self.patch_attr(cleanup.shutil, "which", lambda name: "")
+ with self.assertRaises(cleanup.CleanupError) as caught:
+ self.run_cleanup(stdout="Book it.")
+ self.assertIn("claude", str(caught.exception))
+
+ def test_a_run_that_never_ends(self):
+ def run(cmd, **kwargs):
+ raise subprocess.TimeoutExpired(cmd, 180)
+
+ with mock.patch.object(subprocess, "run", side_effect=run):
+ with self.assertRaises(cleanup.CleanupError) as caught:
+ cleanup.run("uh, book it", self.conf, "the rules")
+ self.assertIn("180", str(caught.exception))
+
+
+class Codex(DikteTest):
+ def setUp(self):
+ super().setUp()
+ self.conf = self.config(cleanup_provider="codex")
+ self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
+
+ def run_cleanup(self, text="uh, book it", **kwargs):
+ patcher, calls = fake_run(**kwargs)
+ with patcher:
+ answer = cleanup.run(text, self.conf, "the rules")
+ return answer, calls[0]
+
+ def test_the_rules_ride_in_front_of_the_transcript(self):
+ answer, cmd = self.run_cleanup(last_message="Book it.\n")
+ self.assertEqual(answer, "Book it.")
+ self.assertEqual(cmd[:2], ["codex", "exec"])
+ self.assertEqual(cmd[-1],
+ "the rules\n\n---\n\n\nuh, book it\n")
+
+ def test_the_answer_is_read_from_the_file_rather_than_the_noise_on_stdout(self):
+ answer, _ = self.run_cleanup(
+ stdout="workdir: /home\nmodel: gpt-5.4\ntokens used 400\n",
+ last_message="Book it.",
+ )
+ self.assertEqual(answer, "Book it.")
+
+ def test_that_file_does_not_stay_behind(self):
+ _, cmd = self.run_cleanup(last_message="Book it.")
+ self.assertFalse(os.path.exists(cmd[cmd.index("-o") + 1]))
+
+ def test_it_may_read_but_not_write_and_has_nobody_to_ask(self):
+ _, cmd = self.run_cleanup(last_message="Book it.")
+ self.assertEqual(cmd[cmd.index("--sandbox") + 1], "read-only")
+ self.assertIn('approval_policy="never"', cmd)
+ self.assertIn("--ephemeral", cmd)
+
+ def test_the_model_is_left_alone_until_one_is_typed_in(self):
+ _, cmd = self.run_cleanup(last_message="Book it.")
+ self.assertNotIn("-m", cmd)
+ self.conf["cleanup_codex_model"] = "gpt-5.4"
+ _, cmd = self.run_cleanup(last_message="Book it.")
+ self.assertEqual(cmd[cmd.index("-m") + 1], "gpt-5.4")
+
+ def test_the_thinking_setting_lands_on_the_nearest_rung_codex_has(self):
+ self.conf["cleanup_reasoning"] = "xhigh"
+ _, cmd = self.run_cleanup(last_message="Book it.")
+ self.assertIn('model_reasoning_effort="high"', cmd)
+
+ def test_an_answer_of_nothing(self):
+ with self.assertRaises(cleanup.CleanupError):
+ self.run_cleanup(stdout="tokens used 400", last_message="")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_cli.py b/tests/test_cli.py
index cb09bc0..9547df2 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -366,6 +366,34 @@ class Providers(DikteTest):
self.assertIn("Groq", out)
+class Doctor(DikteTest):
+ """One pass over everything the settings window checks behind its buttons."""
+
+ def run_doctor(self, as_json=True, **settings):
+ self.write_config(settings)
+ with mock.patch.object(ipc, "send", return_value=None), \
+ captured() as (out, _err):
+ cli.cmd_doctor(Options(json=as_json))
+ return json.loads(out.getvalue()) if as_json else out.getvalue()
+
+ def test_cleanup_on_openrouter_is_a_question_about_the_key(self):
+ reply = self.run_doctor(cleanup_model="some/model")
+ self.assertEqual(reply["cleanup"]["provider"], "openrouter")
+ self.assertEqual(reply["cleanup"]["model"], "some/model")
+ self.assertIn("OpenRouter key, cleaning up on some/model",
+ self.run_doctor(as_json=False, cleanup_model="some/model"))
+
+ def test_cleanup_on_a_cli_is_a_question_about_the_program(self):
+ reply = self.run_doctor(cleanup_provider="codex",
+ cleanup_codex_model="gpt-5.4")
+ self.assertEqual(reply["cleanup"]["provider"], "codex")
+ self.assertEqual(reply["cleanup"]["model"], "gpt-5.4")
+ self.assertIn("codex", reply["programs"])
+ self.assertIn("codex, cleaning up on gpt-5.4",
+ self.run_doctor(as_json=False, cleanup_provider="codex",
+ cleanup_codex_model="gpt-5.4"))
+
+
class Finding(DikteTest):
def test_no_history_at_all(self):
self.assertIsNone(cli._find_history("last"))
diff --git a/tests/test_ui.py b/tests/test_ui.py
index 892e736..e6d1aba 100644
--- a/tests/test_ui.py
+++ b/tests/test_ui.py
@@ -44,7 +44,10 @@ CHANGED = {
"groq_transcribe_model": "whisper-large-v3",
"openrouter_transcribe_model": "openai/whisper-1",
"cleanup_enabled": False,
+ "cleanup_provider": "claude",
"cleanup_model": "some/other-model",
+ "cleanup_claude_model": "opus",
+ "cleanup_codex_model": "gpt-5",
"cleanup_reasoning": "high",
"cleanup_prompt": "Only fix the punctuation.",
"file_cleanup_prompt": "Keep the stamps where they are.",
@@ -128,6 +131,20 @@ class Settings(DikteTest):
with self.subTest(key=key):
self.assertEqual(stored[key], value)
+ def test_the_model_box_on_screen_belongs_to_whoever_cleans_up(self):
+ """An OpenRouter id and a Claude alias are not the same field."""
+ window = self.window(cfg.Config())
+ boxes = {"openrouter": window.cleanup_model_row,
+ "claude": window.cleanup_claude_model,
+ "codex": window.cleanup_codex_model}
+ for provider, box in boxes.items():
+ with self.subTest(provider=provider):
+ window._select_data(window.cleanup_provider, provider)
+ shown = [name for name, other in boxes.items()
+ if not other.isHidden()]
+ self.assertEqual(shown, [provider])
+ self.assertFalse(box.isHidden())
+
def test_the_settings_the_window_does_not_show_are_left_alone(self):
"""A tab nobody wrote must not reset what the command line set."""
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
diff --git a/worker.py b/worker.py
index ba27107..012ab07 100644
--- a/worker.py
+++ b/worker.py
@@ -18,6 +18,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
import api
import assistant
import audio
+import cleanup
import config as cfg
import i18n
import paste
@@ -112,14 +113,7 @@ class Pipeline(QObject):
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
self.stage.emit(t("Cleaning up…"))
try:
- text = api.cleanup(
- raw,
- conf.openrouter_key(),
- conf["cleanup_model"],
- conf.cleanup_prompt(),
- reasoning=conf["cleanup_reasoning"],
- base_url=conf["openrouter_base_url"],
- )
+ text = cleanup.run(raw, conf, conf.cleanup_prompt())
except api.ApiError as exc:
# Keep the transcript, but never let the failure pass unseen:
# a rejected key would otherwise look like working dictation.
@@ -159,7 +153,7 @@ class Pipeline(QObject):
"duration": round(duration, 1),
"elapsed": round(time.monotonic() - started, 1),
"model": target.model,
- "cleanup_model": conf["cleanup_model"] if conf["cleanup_enabled"] else "",
+ "cleanup_model": cleanup.model(conf) if conf["cleanup_enabled"] else "",
"cleanup_error": warning,
"mode": "ask" if ask else "",
"question": question,