Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.10.0 · Text actions on your ChatGPT subscription (on `main`, ships with 1.0)

- Add **Settings → AI → Provider**: OpenAI key or **ChatGPT**. With ChatGPT, Clean, Polish, Summarize, Prompt, Email and your own actions run on your ChatGPT subscription through the Codex CLI login (`codex login`) at no extra cost. Transcription keeps using the OpenAI key or the local model.
- Mark it **Experimental**: OpenAI does not support third-party apps on this path and may block it. There is no automatic fallback to the API key; a failure is shown and the plain transcript is delivered, as with any failed action.
- Choose the ChatGPT model under **Text model** from the models the Codex CLI lists; **Auto** takes the fastest one (GPT-6-Luna today). Effort offers Auto, Low, Medium and High.
- Renew an expiring ChatGPT sign-in and write it back for the Codex CLI, so both stay signed in. Say when the login is missing before a recording starts, and when the usage limit is reached.

See [0.10.0 release notes](docs/releases/v0.10.0.md).

## 0.9.0 · Settings, found at once (on `main`, ships with 1.0)

- Rebuild Settings as one page: a search field that finds a setting in any category, five categories (Recording, Transcription, AI, Output, App) and one row per setting.
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ and the matching rows from every category appear, ready to change right there.
| --- | --- |
| Recording | Microphone, shortcut, sounds |
| Transcription | Engine (Cloud, Live, Local), OpenAI key, language, vocabulary, context, local model |
| AI | Actions (built-in and your own prompts), text model and thinking effort |
| AI | Actions (built-in and your own prompts), provider (OpenAI key or ChatGPT), text model and thinking effort |
| Output | Typing method, keystroke delay, file folder, history |
| App | Theme, start at login, diagnostics |

Expand Down Expand Up @@ -252,6 +252,12 @@ Only what differs from the shipped text is stored, so a prompt you left alone st

Under **Settings → AI → Text model**: the model that runs these prompts (`gpt-5-mini` by default) and its **thinking effort** — Auto, Minimal, Low, Medium or High. Auto leaves the model its own default; lower is faster and cheaper. Not every model offers every level, and one that does not know the level you chose refuses the request, in which case the plain transcript is delivered and the reason is shown.

### On your ChatGPT subscription (experimental)

**Settings → AI → Provider → ChatGPT** runs these prompts on your ChatGPT subscription instead of the API, at no extra cost. It uses the login of the [Codex CLI](https://developers.openai.com/codex/cli): install it and run `codex login` once; Utterform reads that login from `~/.codex/auth.json` (or `$CODEX_HOME`) and renews it when it expires. Transcription still uses your OpenAI key or the local model. **Text model** then lists the models the Codex CLI offers; *Auto* takes the fastest.

OpenAI does not officially support other apps on this path and may block it, which is why it is marked *Experimental*. Utterform does not quietly fall back to your API key: if ChatGPT refuses, the reason is shown and the plain transcript is delivered.

## Vocabulary

Names, products and spellings a model would otherwise guess at go under **Settings → Transcription → Vocabulary**, one word at a time: type it and press Enter, remove it with ×. A pasted list keeps its lines. Write *Careum* there and it stops coming back as *Kareum*.
Expand Down
30 changes: 30 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Utterform uses Tauri 2 as its desktop shell, Rust for all privileged or compute-
- `tray.rs` — Tauri's native tray on Windows/macOS, an own StatusNotifierItem on Linux so a left click arrives; both show a red dot while recording and a five-second green dot after successful delivery
- `actions.rs` — the prompts Utterform ships with, and how a user's replacement resolves against them
- `transcription/openai.rs` — GPT Transcribe and Responses API calls
- `transcription/codex.rs` — text actions on the ChatGPT subscription through the Codex CLI login (experimental); see [Text actions on ChatGPT](#text-actions-on-chatgpt-0100)
- `transcription/local.rs` — blocking local Whisper inference
- `packaging/cmake/portable-cpu.cmake` — the instruction set whisper.cpp is compiled for, and why it is not the build machine's
- `models.rs` — curated model catalog, downloads, progress events, and SHA-256 verification
Expand Down Expand Up @@ -105,6 +106,35 @@ Settings (since 0.9.0) is one page over the main window, using the same theme to

`plain` is in the same list with no prompt at all: it is shown in the editor as the action that never reaches a text model, and has nothing to edit.

## Text actions on ChatGPT (0.10.0)

`AppSettings::text_provider` chooses who runs a text action: `openai_key` (the Responses
API with the keyring key) or `codex` (the user's ChatGPT subscription). Transcription is
never affected. `transcription::transform` dispatches; the instructions resolve the same
way for both (`openai::action_instructions`).

`codex.rs` has no login of its own. It reads `auth.json` from `$CODEX_HOME` or
`~/.codex` (`%USERPROFILE%\.codex` on Windows), sends the access token and account id to
`https://chatgpt.com/backend-api/codex/responses` with `originator: utterform`, and reads
the answer from the server-sent-event stream the endpoint insists on (`stream: true`,
`input` as a message list). An access token within five minutes of its `exp`, or one the
server answers with 401, is renewed once at `auth.openai.com/oauth/token` under the Codex
CLI's public client id. Refresh tokens are single-use, so the renewal is written back to
the same file — atomically, mode 0600 — or the CLI would be signed out; before renewing,
the file is read again and a token the CLI renewed in the meantime is taken instead.
Approach taken from `kit` (jli-software/kit).

Models come from the CLI's `models_cache.json`: only `visibility: list`, in its priority
order. `codex_model: None` means Auto: the first `…-luna`/`…-mini`, else the first listed.
Effort `minimal` is sent as `low`, the Codex models' lowest level.

Decided with Jonas on 2026-11-07: marked *Experimental* in Settings, and **no fallback to
the API key** — a ChatGPT failure is the usual `transform.fallback` to the plain transcript
with ChatGPT's reason. `begin_recording` checks the login like a missing key, before anything
is said. `transform.*` events carry `provider=openai_key|codex`; nothing from `auth.json`
is ever logged or sent to the interface, which gets only `codex_status` (signed in, model
ids and names). `open_codex_setup` opens one fixed URL.

## What the local engine is compiled for

whisper.cpp is built from source by `whisper-rs-sys`, and ggml's CMake targets the machine doing the compiling unless told otherwise. For a release built on someone else's server that is a trap rather than an optimization: the 0.7.2 Linux binary carried the runner's AVX-512 and AMX instructions and was stopped with SIGILL on an Intel N300 while loading a model — inside `ggml_backend_cpu_device_get_extra_buffers_type`, plain C++ frame code, which is before ggml's own runtime dispatch can choose a kernel and therefore beyond its help. The bug had been there since the first release; only the pairing of build machine and user machine decided whether it fired.
Expand Down
13 changes: 12 additions & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ proposals are in [plans/v1.0-roadmap.md](plans/v1.0-roadmap.md). The plan of 202
(reliability, assistant, finish) is superseded; 0.7.13 shipped, 0.7.14
(`feat/simple-settings`) was rejected and will not be merged.

## Current handoff — 0.9.0 Settings from scratch
## Current handoff — 0.10.0 Codex login for text actions

Stage 0.10.0 of [the roadmap](plans/v1.0-roadmap.md), branch `work/utterform-0.10.0-codex`.
Settings → AI → Provider chooses OpenAI key or ChatGPT; with ChatGPT the text actions run
on the subscription through the Codex CLI login (`transcription/codex.rs`, approach from
`kit`). Jonas decided: "Experimental" label, no fallback to the API key; mock-up accepted.
Design in [ARCHITECTURE.md → Text actions on ChatGPT](ARCHITECTURE.md#text-actions-on-chatgpt-0100),
measurements and what was not exercised live in [TESTING.md](TESTING.md). Merged into
`main` without tag or release; ships with 1.0. Next stage: 0.11.0 context — it can build on
either provider through `transcription::transform`.

## Previous handoff — 0.9.0 Settings from scratch

Stage 0.9.0 of [the roadmap](plans/v1.0-roadmap.md), branch `work/utterform-0.9.0-settings`
(worktree `~/git/utterform-settings` on `asus16`), rebased on main/v0.8.0.
Expand Down
20 changes: 20 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@
`PLAYWRIGHT_CHROMIUM_EXECUTABLE=/usr/bin/chromium npm run test:e2e`.
- `cmake` is needed for whisper.cpp. Without root, `mise exec cmake@latest -- cargo test` works.

## 0.10.0 Text actions on ChatGPT

Unit tests in `transcription/codex.rs`: renewal only near expiry, a renewal replacing the
tokens and nothing else, the atomic 0600 write-back, the missing-login message,
`$CODEX_HOME`, the listed models and Auto, the request body and effort mapping, the
event-stream answer, and the refusal messages (401, 429 with reset time, others).
Interface: switching the provider saves at once and shows *Experimental*, *Set up* when
not signed in, the model list with Auto first and no Minimal. Playwright covers the row
at 920×720.

Live, on `asus16` with the real `codex login`:
`dbus-run-session -- cargo test codex_live -- --ignored --nocapture` → GPT-6-Luna, 1.5 s.
A spike with the same Clean prompt: ChatGPT 1.6–2.2 s (GPT-6-Luna, GPT-6.1-Sol, GPT-5.6-Luna,
any effort), API `gpt-5-mini` 5.2–7.5 s. The endpoint refuses `stream: false` and a string
`input`; `originator` and `chatgpt-account-id` are accepted as sent.

Not exercised live: a real token renewal (the current access token lives about ten days;
renewing it on purpose would rotate Jonas's refresh token). It is the path `kit` uses daily.
To check at 1.0: an action with the installed app on Linux, Windows and macOS.

## 0.8.0 Omarchy dictation backend

`scripts/test-omarchy-dictation.sh` (also in CI) installs a stripped release with the real
Expand Down
9 changes: 5 additions & 4 deletions docs/plans/v1.0-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ and without its subscription:
| 2026-11-07 | Settings rules: as little text as possible, **no Save button** (every change applies at once), as simple as possible but clear, everything findable. Do not spend long on it — it has to work. |
| 2026-11-07 | Jonas tests only once, at 1.0, everything together; until then no preview builds for him. Stages go through on their own: merge into `main` when tests and CI are green, no tag or release before 1.0. Style samples (mock-ups, images) are still welcome, and so are occasional questions. The two parallel sessions coordinate with each other. |
| 2026-11-07 | Settings mock-up accepted: search field on top, categories as tabs below, one row per setting. The "Context" category appears only once 0.11.0 gives it rows. |
| 2026-11-07 | 0.10.0: ChatGPT is marked "Experimental" in Settings; **no fallback** to the OpenAI key — a ChatGPT failure is shown and the plain transcript delivered. Mock-up (AI → Provider: OpenAI key \| ChatGPT) accepted. |

Proposals not yet confirmed by Jonas are marked **Proposal**.

Expand All @@ -66,7 +67,7 @@ Proposals not yet confirmed by Jonas are marked **Proposal**.
|---|---|---|
| 0.8.0 | Omarchy dictation backend | released |
| 0.9.0 | Settings from scratch | merged, ships with 1.0 |
| 0.10.0 | Codex login for text actions | planned |
| 0.10.0 | Codex login for text actions | merged, ships with 1.0 |
| 0.11.0 | Context: selected text and clipboard | planned |
| 0.12.0 | Action per application | to discuss first |
| 0.13.0 | Audio files | planned |
Expand Down Expand Up @@ -129,9 +130,9 @@ Rules from the decisions above. Design contract:
- Jonas's `kit` (jli-software/kit, Rust) already does this: it reads the Codex CLI login
from `~/.codex/auth.json` and calls the Responses API at `chatgpt.com/backend-api/codex`.
Reuse that approach; verify token refresh and expiry behaviour before relying on it.
- **Proposal:** mark it "experimental" in the interface. OpenAI does not officially support
third-party apps using the subscription and may block it; Utterform is public. If Codex
fails, fall back to the OpenAI key when one is stored, and say so once.
- Marked "experimental" in the interface (confirmed). OpenAI does not officially support
third-party apps using the subscription and may block it; Utterform is public.
**No fallback** to the OpenAI key (Jonas, 2026-11-07): a failure is shown as such.
- Model list from `~/.codex/models_cache.json` (as in kit); default the smallest fast model.
- Measure latency against the API path; dictation must stay quick.
- Requires the Codex CLI installed and logged in; Settings says so in one line with a link.
Expand Down
32 changes: 32 additions & 0 deletions docs/releases/v0.10.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Utterform 0.10.0 — Text actions on your ChatGPT subscription

*Stage of the road to 1.0, merged into `main`; not released on its own. It ships with 1.0.*

Clean, Polish, Email and the other actions need a text model. Until now that was always
the OpenAI API, paid per request with your key. If you already pay for ChatGPT, that
subscription can now do the work instead.

**Settings → AI → Provider: OpenAI key | ChatGPT.** Choose ChatGPT and the actions run
on your subscription, through the login the Codex CLI keeps on your computer. Utterform
has no login of its own: install the [Codex CLI](https://developers.openai.com/codex/cli)
and run `codex login` once. Until then the row says **Needs `codex login`** with a
**Set up** link, and a recording with an action is stopped before it starts.

**Transcription does not change.** GPT Transcribe with your OpenAI key, or the local
Whisper model, stays as it is. Only the rewriting afterwards moves.

**Experimental.** OpenAI does not officially support other apps on this path and may
close it. Utterform says so next to the choice and does not hide a failure behind your
API key: if ChatGPT refuses, you see why and get the plain transcript, as with any
failed action. Switch back to *OpenAI key* to use the API again.

**Quick.** On `asus16` a Clean through ChatGPT took about 1.5–2 seconds, against 5–7
seconds for `gpt-5-mini` over the API with the same prompt.

**Text model** lists the models the Codex CLI offers. *Auto* takes the fastest one
(GPT-6-Luna today) and follows when OpenAI replaces it. Effort is Auto, Low, Medium or
High; the API's *Minimal* runs as Low here.

When the ChatGPT sign-in is about to expire, Utterform renews it and writes it back to
the Codex CLI's file, so the CLI stays signed in too. When the subscription's usage
limit is reached, the message says when it resets.
63 changes: 51 additions & 12 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::{
audio::{self, AudioCaptureState, RecordingArtifact},
diagnostics::{self, Failure},
domain::{
AppSettings, AudioDeviceInfo, LocalModelInfo, ProcessRequest, ProcessResult,
AppSettings, AudioDeviceInfo, LocalModelInfo, ProcessRequest, ProcessResult, TextProvider,
TranscriptionEngine,
},
feedback::{self, Cue},
Expand Down Expand Up @@ -223,7 +223,21 @@ fn begin_recording(
mode = "batch"
)
};
if engine == TranscriptionEngine::OpenAi || action != actions::PLAIN {
let current_settings = settings::load(app).map_err(|failure| {
diagnostics::failure!(
"settings.load_failed",
&failure,
during = "recording.start",
recording = recording
)
})?;
let codex_text =
action != actions::PLAIN && current_settings.text_provider == TextProvider::Codex;
if codex_text {
// Like a missing key: say so while nothing has been said yet.
transcription::codex::ensure_signed_in().map_err(rejected)?;
}
if engine == TranscriptionEngine::OpenAi || (action != actions::PLAIN && !codex_text) {
// A missing key stops here, while nothing has been said yet. A keyring
// that cannot answer does not: the recording is kept if it still
// cannot answer afterwards, and the words survive the outage.
Expand Down Expand Up @@ -258,14 +272,6 @@ fn begin_recording(
)));
}
}
let current_settings = settings::load(app).map_err(|failure| {
diagnostics::failure!(
"settings.load_failed",
&failure,
during = "recording.start",
recording = recording
)
})?;
// The previous recording's Done cue ends here if it is still sounding,
// before the start cue is scheduled: a chime finishing inside the next
// dictation would confirm the wrong recording, and its tail must not be
Expand Down Expand Up @@ -578,12 +584,19 @@ async fn process_recording(
let effort = current_settings
.text_effort
.map_or("auto", |effort| effort.as_str());
let provider = current_settings.text_provider.as_str();
let text_model = if transforms {
transcription::text_model(&current_settings)
} else {
String::new()
};
if transforms {
diagnostics::info!(
"transform.started",
recording = recording,
action = action,
model = text_model_name(&current_settings.text_model),
provider = provider,
model = text_model_name(&text_model),
effort = effort
);
}
Expand Down Expand Up @@ -616,7 +629,8 @@ async fn process_recording(
&failure,
recording = recording,
action = action,
model = text_model_name(&current_settings.text_model),
provider = provider,
model = text_model_name(&text_model),
effort = effort,
elapsed_ms = transforming.elapsed().as_millis()
)
Expand Down Expand Up @@ -816,6 +830,31 @@ pub fn copy_text(app: AppHandle, text: String) -> Result<(), String> {
}
}

/// Whether the Codex CLI is signed in to ChatGPT, and the models it offers.
/// Never a token or an account.
#[tauri::command]
pub async fn codex_status() -> transcription::codex::CodexStatus {
tauri::async_runtime::spawn_blocking(transcription::codex::status)
.await
.unwrap_or(transcription::codex::CodexStatus {
signed_in: false,
models: Vec::new(),
default_model: None,
})
}

/// The Codex CLI's setup page. A fixed address: the interface cannot ask the
/// backend to open anything else.
#[tauri::command]
pub fn open_codex_setup() -> Result<(), String> {
tauri_plugin_opener::open_url(transcription::codex::SETUP_URL, None::<&str>).map_err(|error| {
diagnostics::failure!(
"codex.setup_open_failed",
&Failure::new("opener", "The Codex setup page could not be opened").detail(error)
)
})
}

#[tauri::command]
pub fn has_openai_api_key() -> bool {
secrets::has_openai_api_key()
Expand Down
Loading
Loading