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
16 changes: 16 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ if ! cargo fmt --all -- --check; then
exit 1
fi

# Two clippy runs, not one, and this is the interesting part.
#
# CI lints WITHOUT `--all-features`, so the feature-off shape of an
# optional crate is a configuration nothing local ever checked. That
# is not hypothetical: the 0.10.0 AI work passed every local check and
# then failed on all three CI platforms, because the HTTP module is
# dead code when `remote` is off. Whichever set you run, run the other
# one too — the cheap one first, since it is the one CI mirrors.
echo "poltertype pre-commit: cargo clippy --workspace --all-targets -- -D warnings (CI's set)"
if ! cargo clippy --workspace --all-targets -- -D warnings; then
echo
echo "poltertype pre-commit: clippy found problems in the DEFAULT feature set."
echo "This is exactly what CI runs. Fix, or commit with --no-verify."
exit 1
fi

echo "poltertype pre-commit: cargo clippy --workspace --all-targets --all-features -- -D warnings"
if ! cargo clippy --workspace --all-targets --all-features -- -D warnings; then
echo
Expand Down
161 changes: 161 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,167 @@ All notable changes to PolterType are recorded here. The format is
loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project follows [Semantic Versioning](https://semver.org/).

## [Unreleased] — 0.9.0

### Added

- **The AI subsystem is a socket you plug your own model into.** Both
shipped backends were stubs that returned no opinion; they are gone.
What replaced them is one detector that speaks three common HTTP
shapes — `openai-chat`, `anthropic-messages`, `ollama-generate` —
and asks a model exactly one question.

**PolterType ships no model, no vendor SDK and no default endpoint.**
What answers is an Ollama on your own machine, an API you hold the
key to, or a gateway of your own, named by you in `[[ai.plugins]]`.
Configure nothing — the default — and there is no AI in PolterType
at all. An entry with neither an `endpoint` nor a `provider` preset
is refused with a message saying exactly that: picking one for you
would be choosing a vendor on your behalf.

Two properties the implementation exists to hold up.

*It cannot slow your typing down.* `judge` runs between you
finishing a word and the word being fixed, so the default mode never
waits: it answers from a cache of already-decided words and queues a
miss for next time. The first time you type a word the model
contributes nothing — exactly what the stubs did for every word —
and everything after it is free, because people retype the same few
thousand words all day. `mode = "blocking"` puts the call inline if
you want it, capped at 250 ms and **refused at startup with the
reason** above that, rather than silently clamped into lag you would
have to diagnose.

*Local is not remote.* `[ai].allow_remote` exists to gate typed
words **leaving your machine**, and a request to `127.0.0.1` does
not leave it — so a model you run yourself needs no network
permission. Requiring one would make people enable access they are
not using. The distinction is decided in one place, resolves no DNS
(a resolver answer can change between the check and the request),
and treats anything unparseable as remote.

What goes on the wire is one word's candidate readings and a fixed
instruction. Not the sentence, not the document, not the focused
application, and **not the layout ids** — those would reveal which
languages you have installed. Keys stay in the OS keychain; a literal
secret in `config.toml` is refused, not used. Without the `remote`
cargo feature no HTTP client is compiled in at all, which `cargo
tree` will confirm.

- **PolterType knows which application you are in on GNOME and KDE.**
`focused_exe()` returned `None` on every Wayland session but
Hyprland, so `[exceptions].disabled_apps`, per-app wordlist profiles
and `apps = [...]` scoping were quietly inert on the two largest
desktops.

The plan was a KWin script plus a GNOME Shell extension — two
out-of-tree artifacts, in two languages, that you would have to
install. It turned out to be unnecessary: AT-SPI events arrive over
the accessibility bus from the *application's own* connection, so
the bus itself can be asked whose it is. One backend, nothing to
install, and it works on any compositor with an a11y bridge.

**Read the limit before relying on it.** Only applications with a
live accessibility bridge are visible — GTK, Qt and Electron answer;
most terminals do not, and a terminal is where developers type. An
app that never emits also never *un*-focuses the previous one, so
observations carry an age and anything older than five minutes counts
as no answer. This is an improvement on nothing, not an equivalent
of a compositor query.

- **The settings window speaks other languages, starting with
Ukrainian.** An app whose whole subject is other people's languages
had an English-only interface.

Translations are data — `data/i18n/<lang>.toml`, one flat table —
and a file in `<config-dir>/poltertype/i18n/` wins over the shipped
one, so a translator can edit and reopen the window without
rebuilding anything. English is compiled into every call site rather
than loaded, so a catalog that fails to parse, a key nobody
translated, or a file a packager forgot degrades to readable English
instead of a blank button. `[general].ui_language` picks; `"system"`
and `"auto"` both follow the environment. Adding a language is one
file — see [docs/TRANSLATING_THE_UI.md](docs/TRANSLATING_THE_UI.md).

- **Smart-command triggers can be more than one word.** `best regards`
now works. The word buffer still resets at every boundary, so the
engine keeps the last four completed words alongside it — bounded by
the same idle timeout that already abandons the buffer, and cleared
when you change application, because half a trigger typed in one
window must not complete in another. It is the one place the engine
holds more of your text than the word you are typing, and it is
sized accordingly.

- **`run_shell` smart commands**, off by default and deliberately
awkward to misuse. PolterType already reads every keystroke; adding
"and can run a program" turns a shared or stolen `config.toml` into
code that fires the next time you type an ordinary word. So it needs
`[commands].allow_run_shell = true`, runs **no shell** — a program
and an argument vector, executed directly, so a metacharacter is
just a character — and never puts anything you typed into an
argument. A timeout, an output cap, no stdin, and dispatch off the
correction path. Inserted output is truncated on a character
boundary, stripped of control characters (a newline typed into a
chat window sends it), and not inserted at all when the command
failed.

- **Language packs have a supported way in.** The loader has read
`<data_dir>/plugins/<id>/` since v0.1, but getting a pack there meant
copying directories by hand with no validation. `install` takes a
directory already on your disk — **there is no download, and that is
the point.** Fetching third-party content into a process that reads
every keystroke is a far wider channel than the updater's signed,
no-payload manifest fetch; a pack you downloaded yourself is a trust
decision you made where you could see it. It also means no archive,
so no zip-slip and no decompression bomb.

Installation copies only what a data-only pack may contain, reports
everything it left behind, refuses symlinks rather than following
them, and replaces atomically — an interrupted install leaves the
old pack or none, never half of a new one.

- **Wayland can type without the setup script — on GNOME and KDE, in
theory.** `uinput` needs `input`-group membership plus a udev rule,
which is the one `sudo` standing between installing PolterType and
it doing anything. The `RemoteDesktop` portal is the standard,
permissioned way to ask a compositor to synthesise input, so it is
now tried **when and only when `uinput` cannot be opened** — nobody
who already ran `scripts/setup-linux.sh` will ever see a consent
dialog.

**This has never run.** There is no RemoteDesktop backend on the
machine it was written on, so it is written from the specification
and executed by nobody — the same standing as the macOS paths, and
it is labelled that way in the code. If it misbehaves on a real
GNOME or KDE session, assume PolterType is wrong before the
compositor.

It takes the portal's `NotifyKeyboardKeycode` rather than `libei`
deliberately: that method does exactly what a correction needs, and
going through `ConnectToEIS` and the libei protocol would have meant
a new protocol implementation and a heavyweight dependency to send
twenty keystrokes — while still needing the same session
negotiation. A restore token is stored so later launches are silent.

### Fixed

- **A `-1` from a model was read as "the first candidate".** Every
model that means "none of these" and writes it as a negative number
would have had a word retyped as something the user did not ask for.

### Changed

- **There will be no AT-SPI keystroke listener**, and this is now a
decision with measurements rather than an open plan item. Registering
one returns false on wlroots and delivers nothing even with keys
injected through `uinput`, because `at-spi2-registryd` has no
keyboard of its own — on Wayland it relays what the compositor hands
it, and only mutter does. Where it *would* work (X11) the existing
listener already needs no permissions. Wayland still needs
`scripts/setup-linux.sh` once; anyone wanting a zero-permission
session has X11 today. See [docs/DECISIONS.md](docs/DECISIONS.md),
2026-08-01.

## [0.9.0] — nine more languages, and a dictionary pipeline that stops failing quietly

### Added
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ typed on an English layout comes out as `ma;ana` — PolterType fixes the
word the moment it ends and switches the layout with it, so `por la
tarde` lands correctly as typed.*

> **Status:** v0.9.0 — out of beta since v0.1.0. Works end-to-end on
> **Status:** v0.10.0 — out of beta since v0.1.0. Works end-to-end on
> Windows and on Linux (both Wayland and X11); the spelling-
> suggestions tooltip renders on Hyprland, Sway, KDE Plasma and X11
> (GNOME Wayland gets it through XWayland). On Linux/Wayland a
Expand Down Expand Up @@ -120,7 +120,7 @@ is two requests: a `GET` of a small JSON manifest, and — only when
there's actually a new version — a `GET` of the installer itself. No
account, no identifier, nothing about you and nothing about what you
type. What GitHub can see is what any download reveals: your IP, and
a User-Agent naming the running version (`PolterType/0.9.0
a User-Agent naming the running version (`PolterType/0.10.0
(updater)`). The exact manifest URL is printed on the Settings
window's **General** pane, so you never have to take our word for it.

Expand Down Expand Up @@ -330,12 +330,15 @@ explicitly:
windows developers type in. Add the entries you want, or manage
them on the **Exceptions** pane in Settings.

> **The skip list needs a focus tracker, and one doesn't exist
> everywhere.** Reading which application has focus is implemented on
> Windows, Hyprland and X11. On macOS and on non-Hyprland Wayland
> (GNOME/KDE) the tracker is a no-op, so the per-app skip list, the
> per-app wordlist profiles below, and the `apps = [...]` scoping on
> smart commands silently do nothing there.
> **The skip list needs a focus tracker, and it isn't equally good
> everywhere.** Reading which application has focus is complete on
> Windows, Hyprland and X11. On other Wayland sessions (GNOME, KDE)
> PolterType asks the accessibility bus instead — which works, but
> only for applications that expose an accessibility bridge. Most
> terminals don't, so the per-app skip list, per-app wordlist
> profiles, and `apps = [...]` scoping on smart commands may simply
> not fire there. On macOS the tracker is still a no-op and they do
> nothing at all.

### Adding your own vocabulary

Expand Down
20 changes: 15 additions & 5 deletions crates/poltertype-ai/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ workspace = true

[features]
default = []
# Enabling `remote` brings in reqwest + native-tls and lets the
# RemoteLlmDetector make real HTTP calls. Off by default; users opt
# in via `[ai].allow_remote = true` AND a feature-enabled build.
# `remote` is what puts an HTTP client in the binary. Without it this
# crate compiles with no `reqwest` anywhere in the tree — the claim
# "a stock build cannot make an AI call" is checkable with
# `cargo tree`, not just documented. Users opt in with this feature
# AND `[ai].allow_remote = true` for a non-loopback endpoint.
remote = ["dep:reqwest"]

[dependencies]
Expand All @@ -27,5 +29,13 @@ thiserror = { workspace = true }
tracing = { workspace = true }
keyring = { workspace = true }

# Activated only when `--features remote` is on.
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"], optional = true }
# Activated only when `--features remote` is on. No `json` feature:
# request bodies are built and read in `wire.rs`, which keeps the
# whole request/response contract testable on hosts where this
# dependency does not exist.
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"], optional = true }

[dev-dependencies]
# Only to prove the hand-rolled JSON escaping in `wire.rs` produces
# bodies a real parser accepts. Never linked into a shipped binary.
serde_json = { workspace = true }
107 changes: 107 additions & 0 deletions crates/poltertype-ai/src/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! The decided-word cache.
//!
//! This is what makes an LLM usable on the correction path at all. A
//! query takes anywhere from 30 ms on a warm local model to several
//! seconds on a hosted API; a correction has to happen in the pause
//! between two words. So the detector never waits by default — it
//! answers from here, and a miss queues the question so the *next*
//! occurrence is decided.
//!
//! That trade is only worth making because of how people type. The
//! same person types the same few thousand words over and over, so a
//! cache this small reaches a high hit rate within a session and the
//! cost is a one-off "no opinion" the first time a word appears —
//! which is exactly what the detector returned before there was a
//! backend at all.
//!
//! **What is stored is a verdict, not text a human can read back.**
//! Keys are hashes of the candidate list, never the words themselves,
//! and nothing here is written to disk. A memory dump of a running
//! process is out of scope, but a cache that quietly accumulated a
//! plain-text record of everything typed would not be.

use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};

/// A remembered answer: the index the model chose among the candidates
/// it was given, or `None` for "none of these".
pub type Decision = Option<usize>;

/// Fixed-capacity map from question-hash to decision, with a
/// second-chance eviction: once full, insertion clears the oldest
/// half. Cruder than a true LRU and deliberately so — it needs no
/// per-entry bookkeeping on the read path, which is the path that
/// runs while the user is mid-correction.
pub struct DecisionCache {
entries: HashMap<u64, Decision>,
order: Vec<u64>,
capacity: usize,
}

impl DecisionCache {
pub fn new(capacity: usize) -> Self {
Self {
entries: HashMap::with_capacity(capacity.min(1024)),
order: Vec::with_capacity(capacity.min(1024)),
capacity,
}
}

/// Hash a question into a cache key.
///
/// The candidate list *is* the question — same renderings, same
/// answer — so it alone determines the key. Hashing rather than
/// storing means the cache holds no recoverable copy of what was
/// typed.
pub fn key(candidates: &[String]) -> u64 {
let mut h = DefaultHasher::new();
candidates.len().hash(&mut h);
for c in candidates {
c.hash(&mut h);
}
h.finish()
}

pub fn get(&self, key: u64) -> Option<Decision> {
self.entries.get(&key).copied()
}

pub fn insert(&mut self, key: u64, decision: Decision) {
if self.capacity == 0 {
return;
}
// An existing key is an update, not a new occupant: it must
// not take a second slot in `order`, or a word re-decided a
// few times would evict everything around it.
if let std::collections::hash_map::Entry::Occupied(mut e) = self.entries.entry(key) {
e.insert(decision);
return;
}
if self.entries.len() >= self.capacity {
// Drop the oldest half in one pass rather than one entry
// per insert: amortised, and it keeps `order` from needing
// an O(n) remove on every write.
let cut = self.order.len() / 2;
for old in self.order.drain(..cut) {
self.entries.remove(&old);
}
}
self.entries.insert(key, decision);
self.order.push(key);
}

/// Live entry count. Not used on any hot path — it exists so the
/// eviction policy can be asserted rather than assumed.
#[cfg(test)]
pub fn len(&self) -> usize {
self.entries.len()
}

#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}

#[cfg(test)]
mod tests;
Loading