# pi-ghost-autocomplete

[English](README.md) | [Deutsch](README.de.md) | [Français](README.fr.md) | [Español](README.es.md) | [中文](README.zh-CN.md) | [日本語](README.ja.md) | [Português](README.pt-BR.md) | [Italiano](README.it.md) | [Русский](README.ru.md)

Inline ghost-text autocomplete for the [Pi coding agent](https://pi.dev/) (`@earendil-works/pi-coding-agent`).

While you type at the Pi prompt, an LLM predicts the most likely continuation
of the current line and renders it as dim grey "ghost text" right of the
cursor. Press **Right Arrow** or **Tab** (when no slash popup) to accept. The cursor never moves until you do,
the existing slash-command popup keeps **Tab**, and a slow or unreachable
backend silently disables itself rather than spamming the chat.

**Multilingual** (session 52): Unicode-aware word segmentation handles German
umlauts, French/Spanish diacritics, CJK scripts, and combining marks.
Register-based routing ensures general prose is completed in-language without
being reframed as a coding-agent instruction.

Four provider modes ship in the same package:

| Mode | Backend | Default debounce | Default model |
| --- | --- | --- | --- |
| `cloud` | Groq / Cerebras / OpenRouter via `@earendil-works/pi-ai` | 150 ms | `groq/llama-3.1-8b-instant` |
| `local` | Ollama / vLLM (OpenAI-compatible) | 400 ms | `qwen2.5-coder:1.5b` on `http://localhost:11434` |
| `openai-compat` | Any OpenAI-compatible REST API (e.g. Mercury Edit) — also used as the **default** provider | 150 ms | `mercury-edit-2` on `https://api.inceptionlabs.ai/v1` |
| `race` | Multiple providers in parallel; first valid response wins | 400 ms | per-member defaults |

**Pi compatibility:** pi ≥ 0.84 — features are detected at runtime, so nothing
breaks on older hosts. Verified against pi 1.2.0 (typechecks and tests
clean against 1.3.0 as well).
Version-gated extras: streaming ghosts, theme dim color, and intent
classification need pi ≥ 0.99; Radius zero-key auto-pick needs pi ≥ 1.0;
Cloudflare Clef classifier models need pi ≥ 1.0.1; aborted-run warmup skip,
GPT-6 Luna, and llama.cpp native decision classifiers need pi ≥ 1.1;
cost-ranked auto-pick and per-key OpenRouter model resolution light up on
pi ≥ 1.2 (older hosts keep the name-based picker — nothing breaks, prices
are just absent).

## Getting started

### 1. Get a Mercury Edit 2 API key

The default provider is **Mercury Edit 2** by Inception Labs — a diffusion-based model optimised for low-latency code completions. Sign up at [platform.inceptionlabs.ai](https://platform.inceptionlabs.ai) to get an API key.

Set the `INCEPTION_API_KEY` environment variable so the extension can reach the API. Add it to your shell profile (e.g. `~/.bashrc`, `~/.zshrc`, or PowerShell `$PROFILE`) for persistence:

```bash
# ~/.bashrc or ~/.zshrc
export INCEPTION_API_KEY="your-key"
```

```powershell
# PowerShell $PROFILE
$env:INCEPTION_API_KEY = "your-key"
```

Or pass it inline for a single session:

```bash
INCEPTION_API_KEY="your-key" pi
```

```powershell
$env:INCEPTION_API_KEY = "your-key"; pi
```

To use a different provider instead (Groq, Cerebras, a local model, …), see [Configuration](#configuration).

### 2. Install the extension

```bash
pi install npm:pi-ghost-autocomplete
```

This registers the extension globally. Pi picks it up automatically on the next launch. To install it only for a single project, add `-l`:

```bash
pi install npm:pi-ghost-autocomplete -l   # writes to .pi/ in the current directory
```

### 3. Launch Pi and try it

```bash
pi
```

Start typing at the prompt. A dim ghost text should appear to the right of your cursor within ~150 ms. Press **Right Arrow** to accept it, or keep typing to dismiss it. The first ghost of a session shows a one-shot `[→ accept]` hint.

> No API key at hand? Skip step 1 and use [any model you already configured in Pi](#use-any-pi-model-no-extra-api-key) as the ghost backend.

| Key | Action |
| --- | --- |
| **→** / **Tab** | Accept full ghost (Tab only when no slash-command popup; configure via `PI_GHOST_ACCEPT_KEYS`) |
| **Ctrl+→** | Accept next word |
| **Shift+↓** / **Shift+↑** | Next / previous candidate (configure via `PI_GHOST_CYCLE_KEYS`) |
| **Esc** | Dismiss ghost |
| Keep typing | Dismiss |

Run `/ghost keys` anytime for the same cheat-sheet. Temporary mute: `/ghost snooze` (default 5m) or `/ghost snooze 30s` — wake with `/ghost on`.

Switch the completion model without leaving Pi:

- **`/ghost model`**: shows the active model and where the config lives.
- **`/ghost model provider/model-id`**: switches to any model your Pi session can authenticate (same resolution as `PI_GHOST_PROVIDER=pi`), effective immediately.
- **`/ghost model model-id`** (bare id): keeps the current backend and swaps its model id.

The choice is persisted to `<agent-dir>/ghost.json` and reapplied at every startup. Why a file: a runtime switch that dies with the session would force you back to env vars, and editing a shell profile mid-session is jarring. Env still wins: when `PI_GHOST_MODEL` or `PI_GHOST_PROVIDER` is set, the file is ignored at startup so a deliberate pin is never silently overridden (`/ghost model` warns about it).

> **The first session feels "cold."** The trie, cache, and profile all start
> empty, so every ghost is a fresh LLM call — slower and more generic. It gets
> noticeably faster and more accurate as you accept completions (the trie learns
> your patterns) and the cache warms up. Give it ~15–20 min of real use before
> judging quality.

Run `/ghost` to confirm the extension loaded and see the active configuration:

```
Pi Ghost: enabled
  minChars=3
  maxLineLength=240
  mode=openai-compat
  provider=mercury-edit
  baseUrl=https://api.inceptionlabs.ai/v1
  model=mercury-edit-2
  apiKey=4e24***
  debounceMs=150
  maxTokens=64
  maxRecentMessages=4
  config: ~/.pi/agent/ghost.json
```

### 4. (Optional) Load the extension without installing

Pass `-e` to load the package for a single session without a permanent install:

```bash
pi -e npm:pi-ghost-autocomplete
```

## Install

```bash
npm install pi-ghost-autocomplete
```

## Configuration

All knobs are environment variables, plus one small config file:

- **`<agent-dir>/ghost.json`** holds only what `/ghost model` persists (the chosen completion model). `~/.pi/agent` by default; env `PI_GHOST_MODEL` / `PI_GHOST_PROVIDER` override it at startup.

The env knobs:

| Variable | Default | Effect |
| --- | --- | --- |
| `PI_GHOST_DISABLED` | unset | Set to `1` to disable on launch |
| `PI_GHOST_HINTS` | on | Set to `off` (or `0`/`false`/`no`) to hide the one-shot `[→ accept]` onboarding suffix on the first ghost |
| `PI_GHOST_ACCEPT_TAB` | on | Legacy: set to `off` so Tab never accepts (ignored when `PI_GHOST_ACCEPT_KEYS` is set) |
| `PI_GHOST_ACCEPT_KEYS` | `right,tab` | Comma list of full-accept keys: `right`, `tab` (e.g. `right` only, or `tab,right`) |
| `PI_GHOST_CYCLE_KEYS` | `shift+down,shift+up` | Candidate-cycling keys as a `next,prev` pair of key ids. Why Shift+arrows by default: pi's fullscreen prompt-jump consumes Ctrl+↑/Ctrl+↓ before the editor sees them, and the pre-0.21 Alt+]/Alt+[ were terminal-hostile |
| `PI_GHOST_PROVIDER` | unset (Mercury Edit 2) | `cloud`, `local`, `pi`, or `router` — set explicitly to switch away from Mercury Edit 2 (ignored when `PI_GHOST_RACE_PROVIDERS` is set). See [Use any Pi model](#use-any-pi-model-no-extra-api-key) for `pi` and [Register routing](#register-routing) for `router` |
| `PI_GHOST_PROVIDER_NAME` | `groq` | Cloud only (`PI_GHOST_PROVIDER=cloud`): any `KnownProvider` from `pi-ai` (`groq`, `cerebras`, `openrouter`, …) |
| `PI_GHOST_MODEL` | provider-specific | Model id, e.g. `llama-3.1-8b-instant`, `qwen2.5-coder:1.5b`. With `PI_GHOST_PROVIDER=pi`: a `provider/model-id` spec (e.g. `groq/llama-3.1-8b-instant`, including dynamic providers like `radius`) or a bare `model-id` matched across the models available in your Pi session. If unset with `PI_GHOST_PROVIDER=pi` (Pi ≥ 1.0), a fast model is auto-picked from the session registry — see [Zero-key setup with Radius](#zero-key-setup-with-radius-pi--10) |
| `PI_GHOST_API_KEY` | session registry | Cloud only: explicit API key. When unset, ghost first reuses the provider key from your Pi session (the same credential the main agent uses — `models.json` config, stored OAuth/env keys), then falls back to `pi-ai`'s ambient env lookup. So a provider already configured for Pi (e.g. `GROQ_API_KEY` or `pi auth login`) needs **no separate ghost key** |
| `PI_GHOST_BASE_URL` | `http://localhost:11434` | Local only: Ollama / vLLM base URL |
| `PI_GHOST_DEBOUNCE_MS` | 150 (cloud) / 400 (local) | Inputs are debounced this long before a request is sent |
| `PI_GHOST_DEBOUNCE_AGENT_MULT` | `1.5` | Router only: multiplier on the base debounce for `agent-message` register requests (instructions, questions). Longer pause reduces wasted API calls during deliberate typing |
| `PI_GHOST_DEBOUNCE_PROSE_MULT` | `1.8` | Router only: multiplier on the base debounce for `general-prose` register. Free prose is the most deliberate register — the longest debounce |
| `PI_GHOST_CHAT_ABORT_THRESHOLD` | `3` | Chat-leg abort-storm backoff (≥ 0.19): number of consecutive **typing-caused** aborted NL requests before storm mode engages (0.19.1: teardown/commit/accept aborts never count). Storm mode requires a pause scaled to the measured chat latency before firing again, instead of the plain debounce |
| `PI_GHOST_CHAT_BACKOFF_MIN_MS` | `600` | Chat-leg backoff: floor for the storm-mode pause (clamped 150–3000) |
| `PI_GHOST_CHAT_BACKOFF_MAX_MS` | `2000` | Chat-leg backoff: ceiling for the storm-mode pause (clamped 300–10000; raised to `MIN` if lower) |
| `PI_GHOST_CHAT_IDLE_FACTOR` | `0.9` | Chat-leg backoff: storm pause = measured latency EWMA × this factor (clamped 0.3–1.5) |
| `PI_GHOST_CHAT_LATENCY_ALPHA` | `0.3` | Chat-leg backoff: EWMA smoothing for chat round-trip latency (clamped 0.05–1) |
| `PI_GHOST_CHAT_STORM_DECAY_MS` | `30000` | Chat-leg backoff: quiet period that resets the abort streak (clamped 1000–300000) |
| `PI_GHOST_MAX_TOKENS` | 64 (cloud) / 48 (local) | Cap on completion tokens |
| `PI_GHOST_RECENT` | 4 (cloud) / 2 (local) | Number of recent user/assistant messages bundled as context |
| `PI_GHOST_MIN_CHARS` | 3 | Minimum draft length before a prediction fires |
| `PI_GHOST_MAX_LINE` | 240 | Predictions skipped if the current line exceeds this length |
| `PI_GHOST_TEMPERATURE` | `0` | Sampling temperature [0, 2] for the FIM / cloud / local legs. `0` is best-effort deterministic; see note below. Does **not** affect the router chat leg — use `PI_GHOST_CHAT_TEMPERATURE` for that |
| `PI_GHOST_MERCURY_PARALLEL_N` | `1` | Mercury FIM only: fire N parallel candidate completions for cycling. Requires `PI_GHOST_TEMPERATURE > 0` (downgrades silently to 1 at temp=0). Costs scale linearly with N |
| `PI_GHOST_SAMPLING_PARAMS` | unset | FIM only: arbitrary OpenAI-compatible sampling knobs as a JSON object (e.g. `{"top_p":0.9,"repetition_penalty":1.1}`), spread into the `/fim/completions` body. Core fields (`model`, `prompt`, `suffix`, `max_tokens`, `temperature`) always win over same-named keys here — set those via their own knobs. Unknown keys pass through verbatim; a strict backend may reject them |
| `PI_GHOST_CHAT_MODEL` | `mercury-3` | Router only: chat model id for the natural-language leg (`/chat/completions`). With `PI_GHOST_CHAT_PROVIDER=pi`: the `provider/model-id` or bare `model-id` spec of any Pi-registry model |
| `PI_GHOST_CHAT_PROVIDER` | unset | Router only: set to `pi` to route the natural-language leg through the Pi registry ([any model you configured in Pi](#use-any-pi-model-no-extra-api-key)) instead of an OpenAI-compatible endpoint |
| `PI_GHOST_CHAT_BASE_URL` | = FIM base URL | Router only: override the chat leg's endpoint base (defaults to the same vendor as the FIM leg) |
| `PI_GHOST_CHAT_API_KEY` | = Inception/FIM key | Router only: override the chat leg's API key (defaults to the FIM leg's key) |
| `PI_GHOST_CHAT_MAX_TOKENS` | `256` | Router only: cap on chat-leg completion tokens. Independent of `PI_GHOST_MAX_TOKENS` — Mercury chat consumes budget on internal reasoning before emitting visible content, so it needs far more headroom than code-FIM. At `64` the chat leg returned `content=null` on nearly every request (session 52) |
| `PI_GHOST_CHAT_TEMPERATURE` | `0.75` | Router only: chat-leg sampling temperature, independent of `PI_GHOST_TEMPERATURE`. Inception's `/chat/completions` supports `[0.5, 1]`; `0` is silently coerced to `0.75` server-side |
| `PI_GHOST_CHAT_REASONING_EFFORT` | `instant` (Mercury-only) | Router only (pi-ghost ≥ 0.18): `reasoning_effort` sent to Mercury's `/chat/completions` (mercury-3, mercury-2.5 and mercury-2) — `instant`/`low`/`medium`/`high` (case-insensitive), or `off`/`none` to omit the field. Invalid values fall back to the default. The `instant` default applies only while the chat leg targets the Inception endpoint with a `mercury-*` model; retargeting `PI_GHOST_CHAT_MODEL`/`PI_GHOST_CHAT_BASE_URL` drops it (unsupported fields 400 on backends without reasoning support — preflight sends it too, so misfires surface there). **Caution**: on mercury-3, `low`/`medium` often spend the whole budget on hidden reasoning and return empty completions. A 2026-10-19 smoke (5 ghost drafts) measured `medium` 3/5 empty at 256 tokens and 4/5 empty at 512, so **more tokens do not fix it** (reasoning scales with the budget): lower the effort instead and watch `token-budget-exhausted` in `/ghost diag` |
| `PI_GHOST_INTENT_CLASSIFIER` | `off` | Track C (pi ≥ 0.99): refine low-confidence intent classifications with a pi classifier model — `auto` (best available: Jev, then local llama.cpp decision models, then any), `on`/`1`/`true` (= `auto`), or an explicit `provider/model-id` for any classifier your Pi session can reach (`reflex/multilingual`, `cloudflare-workers-ai/@cf/cloudflare/clef-flash`, `openai/gpt-6-luna`; measured quality guidance below). Consults are debounced (120 ms), abort-capped (400 ms), TTL-cached, and only fire for the two weak heuristic spots (single-line prose, fallback instruction); a missing or slow verdict changes nothing. See [Intent classification refinement](#intent-classification-refinement) |
| `PI_GHOST_INTENT_CLASSIFIER_TIMEOUT_MS` | `400` | Track C: abort timeout per classify call (clamped 50–5000) |
| `PI_GHOST_INTENT_CLASSIFIER_MIN_CONFIDENCE` | `0.6` | Track C: minimum answer confidence to apply a verdict (clamped 0–1) |
| `PI_GHOST_INTENT_CLASSIFIER_DEBOUNCE_MS` | `120` | Track C: typing-pause debounce before a consult fires (clamped 0–1000); bursts collapse into one call |
| `PI_GHOST_TURN_PREFETCH` | `on` | Track D: on `agent_settled`, prefetch completions for your habitual next-prompt openers (derived from your own recent messages) into cache + speculative ring, so the first keystroke after a reply can serve instantly. Disable with `off` (also `0`/`false`/`no`). See [Turn-boundary warmup](#turn-boundary-warmup) |
| `PI_GHOST_TURN_PREFETCH_MAX` | `2` | Track D: max openers to prefetch per turn (clamped 1–4); requests fire sequentially |
| `PI_GHOST_TURN_PREFETCH_TIMEOUT_MS` | `4000` | Track D: abort timeout per warmup prediction (clamped 500–30000) |
| `PI_GHOST_CONTEXT_MAX_SCAN` | `200` | M6b: cap on the candidate pool scanned for relevance ranking |
| `PI_GHOST_CONTEXT_MAX_SELECTED` | `8` | M6b: max number of selected context entries |
| `PI_GHOST_CONTEXT_MAX_CHARS` | `4000` | M6b: total char budget for the selected context window |
| `PI_GHOST_CONTEXT_BM25_WEIGHT` | `0.3` | M6b: weight of BM25 content score vs recency (0=pure recency, 1=pure relevance) |
| `PI_GHOST_CONTEXT_TOOL_MAX` | `3` | M6c: max number of tool entries to include in selected context (0 disables tools entirely) |
| `PI_GHOST_FIM_CONTEXT_ALL_INTENTS` | unset | Mercury FIM only: by default the conversation-context block is dropped from the FIM prompt for `prose`/`question` intents (a code-FIM edit model returns empty far more often when fed a large natural-language context — see session 51 investigation). Set to `1` to keep context for every intent (A/B against the empty-rate fix) |
| `PI_GHOST_ALLOW_INSECURE` | unset | Set to `1` to allow `http://` provider base URLs on non-localhost hosts. Off by default — API keys + prompts would be sent in clear |
| `PI_GHOST_PROFILE` | unset | Set to `off` to disable persistent profile capture + reranking. Default: enabled. Profile is local-only — see [Profile](#user-profile) below |
| `PI_GHOST_PROFILE_DECAY_DAYS` | `90` | Days before an unused profile record's weight halves during compaction |
| `PI_GHOST_PROVISIONAL` | unset | Set to `off` to disable the 150 ms provisional fallback. Default: enabled |
| `PI_GHOST_PROVISIONAL_PREFER` | unset | Set to `1`/`true`/`on`/`yes` to bias swap decisions toward the already-displayed provisional. Raises the LLM-vs-provisional swap margin from 0.1 to 0.3, reducing perceived flicker at the cost of recall. Bench reports a `flicker=X%` rate so this knob can be evaluated |
| `PI_GHOST_MIN_CONFIDENCE` | `0.55` | M3c.1: minimum reranker score for the top candidate to be shown. Range `[0, 1]`. Lower = more lenient, more ghosts shown |
| `PI_GHOST_RERANK_WEIGHTS` | `0.45,0.25,0.15,0.15` | M3c: reranker weights as `logprob,profileBias,lengthPrior,agreement`. Negative values clamped to 0 |
| `PI_GHOST_EVAL_CAPTURE` | unset | Set to `1` to capture committed prompts to `.pi/ghost-autocomplete/eval.jsonl` for offline replay (`pi-ghost-replay`). **Sensitive**: contains raw prompt text. Off by default |
| `PI_GHOST_REGRET_WINDOW_MS` | `3000` | After accept, window in ms during which a >50% deletion of inserted text flags the record `acceptRegret: true` |
| `PI_GHOST_SHORT_HOVER_MS` | `800` | Enhancement #3: a ghost dismissed within this many ms is flagged `shortHoverDismiss: true` and triggers a soft trie penalty (magnitude 0.25). Tunes the bench `short-hover` rate |
| `PI_GHOST_METRICS` | unset | Set to `1` to write metrics to `.pi/ghost-autocomplete/metrics.jsonl` |
| `PI_GHOST_DEBUG_LOG` | unset | Set to `1` to write debug records to `.pi/ghost-autocomplete/debug.jsonl` |

### Note on `temperature=0`

`temperature=0` is the default and asks the provider for greedy decoding so
the same prefix produces the same completion. This is **best-effort, not
guaranteed**: vendors batch requests across users and kernel scheduling
differences can produce different greedy paths from one call to the next.
The session's in-memory completion cache is the real determinism guarantee
— retyping the same prefix in the same context will reuse the previous
ghost rather than re-querying. Raise temperature only if you want
cache-miss diversity (e.g. `PI_GHOST_TEMPERATURE=0.5`).

Run `/ghost` inside Pi to see the effective config and the config file path;
`/ghost on` and `/ghost off` toggle the feature for the current session.
`/ghost model <spec>` switches the completion model at runtime and persists
the choice to `<agent-dir>/ghost.json` (env still wins at startup).

## Use any Pi model (no extra API key)

`PI_GHOST_PROVIDER=pi` turns ghost completions into ordinary Pi model calls:
the extension resolves the model through the session's own model registry and
lets **Pi handle authentication and endpoints** — `models.json` keys, stored
credentials, `pi auth login`, OAuth — exactly as the main agent sees them. No
`PI_GHOST_API_KEY`, no ghost-specific base URL.

```bash
# Any model your Pi session can use, including dynamic providers:
export PI_GHOST_PROVIDER=pi
export PI_GHOST_MODEL=groq/llama-3.1-8b-instant
# Bare model ids work too (matched across models with working credentials):
# export PI_GHOST_MODEL=llama-3.1-8b-instant
```

### Zero-key setup with Radius (Pi ≥ 1.0)

Pi 1.0 offers **Sign in with Radius** at the top level of `/login`, so the
session registry carries usable models with no ghost-specific key and no
`models.json` editing. With `PI_GHOST_PROVIDER=pi` and **no**
`PI_GHOST_MODEL`, the extension auto-picks a confidently-fast completion
model (llama-8b class → Gemini Flash → Claude Haiku → *mini* tiers →
generic small/fast names) from the models your session can actually use,
and prints the pick at startup:

```bash
pi   # /login → “Sign in with Radius”, then:
export PI_GHOST_PROVIDER=pi   # that's it — no PI_GHOST_MODEL needed
```

Auto-pick is deliberately conservative: if nothing in the registry looks
fast *yet*, the pick retries on every completion — it comes alive right
after `/login` — instead of burning a frontier model every few keystrokes.
Set `PI_GHOST_MODEL` explicitly to override the pick.

On Pi ≥ 1.2 the pick is also **cost-aware**: when several candidates match
the same fast tier, the cheapest one (input+output per 1M, negative
placeholder prices clamped to 0) wins; on older hosts the legacy
shortest-name tie-break applies. With an OpenRouter key this composes with
[per-key model lists](https://pi.dev) — the registry only offers models
your key may use, each with its real prices, so auto-pick chooses the
cheapest usable fast model. (`PI_GHOST_PROVIDER=pi` or a `cloud` leg both
see per-key models on Pi ≥ 1.2; for OpenRouter **regional endpoints**
configured as a `baseUrl` in `models.json`, use the `pi` provider — the
`cloud` leg always targets the provider's default endpoint.)

Pick something small and fast — a chat model that reasons for seconds per
call will feel nothing like autocomplete. Streaming ghosts need Pi ≥ 0.99
(`registry.stream`); on older hosts the same backend answers non-streaming.

This also works as the natural-language leg of
[register routing](#register-routing), so prose suggestions can come from the
same model you chat with while code ghosts keep coming from a FIM model:

```bash
export PI_GHOST_PROVIDER=router
export PI_GHOST_CHAT_PROVIDER=pi
export PI_GHOST_CHAT_MODEL=anthropic/claude-haiku-4.5
```

## Theme-aware ghost text

On Pi ≥ 0.99 the ghost's dim color is taken from the active theme's `dim`
token (`ctx.ui.theme`), which — with Pi's default `system` theme — is derived
from your terminal's own palette and follows light/dark switches. On Pi ≥ 1.0
(⩾ 0.99 feature-detected) the `→ ` / `◌ ` markers and the one-shot
`[→ accept]` hint use the theme's `muted` token — one visibility step above
the suggestion text. On older hosts the extension falls back to the
historical fixed dim-gray. No configuration needed; this is automatic.

## Intent classification refinement

The regex heuristic that routes FIM-vs-chat has two documented weak spots,
both marked low-confidence internally:

1. **Single-line prose** — deciding between `general-prose` ("continue my
   text") and `agent-message` ("continue my request to the assistant") is
   guesswork without multi-line context. Non-English instructions that miss
   the English verb/markers lists land here.
2. **The fallback** — nothing matched: often a short non-English instruction
   *or* a code-shaped command like `npm install` (no path slash, no verb).

On pi ≥ 0.99 you can refine exactly those cases with a classifier model:

```bash
export PI_GHOST_INTENT_CLASSIFIER=auto          # best available: Jev, then local decision models
# or pin one explicitly:
# export PI_GHOST_INTENT_CLASSIFIER=typesafe/jev-latest
# Cloudflare Clef Flash — hosted tiny classifier, zero local setup
# (pi ≥ 1.0.1; needs a Cloudflare key configured in your Pi session):
# export PI_GHOST_INTENT_CLASSIFIER=cloudflare-workers-ai/@cf/cloudflare/clef-flash
# OpenAI GPT-6 Luna — decision model via the Decisions API
# (pi ≥ 1.1; needs OPENAI_API_KEY configured in your Pi session):
# export PI_GHOST_INTENT_CLASSIFIER=openai/gpt-6-luna
# Inception Mercury Decide — decision model from the completion legs'
# vendor; the :free variant costs nothing on OpenRouter:
# export PI_GHOST_INTENT_CLASSIFIER=openrouter/inception/mercury-decide:free
# pi-reflex — local ONNX classifiers: zero keys, zero egress, zero cost
# (pi ≥ 1.0; measured weak on this task — see guidance below):
# export PI_GHOST_INTENT_CLASSIFIER=reflex/multilingual
```

Design guarantees:

- **Never on the critical path** — consults fire asynchronously during the
  typing pause; the debounced completion request reads the verdict only if it
  has already landed. No verdict → the heuristic decides, as before.
- **Bounded cost** — keystroke bursts collapse into one call (120 ms
  debounce), each call aborts after 400 ms, and verdicts are cached per
  line-prefix for 60 s, so a classified line sticks while it grows.
- **Strong signals never pay** — command prefixes, path shapes, question
  markers, and verb-list hits never consult; a verdict can only refine a
  low-confidence classification, never override a decisive one.
- Completion cache keys now include the register, so a verdict flip can never
  re-serve a ghost generated under the other persona.

Run `/ghost` to see `intentClassifier=<spec>` in the status block. Requires a
classifier model your pi session can reach — Jev via `typesafe`, llama.cpp
native decision models (pi ≥ 1.1 with llama.cpp ≥ 0.6.0: Julia-1, Laya, Kev,
lev, and OpenJev answer through `/v1/systemone` — local, free, and comfortably
inside the 400 ms abort budget), Cloudflare's Clef / Clef Flash
(`cloudflare-workers-ai/@cf/cloudflare/clef-flash`, pi ≥ 1.0.1; a hosted tiny
classifier with no local setup), OpenAI's GPT-6 Luna (`openai/gpt-6-luna`,
pi ≥ 1.1, via the Decisions API with `OPENAI_API_KEY`), or any llama.cpp chat
model (pi answers classification from next-token label probabilities). With
`auto`, the pick order is Jev → local llama.cpp decision models → anything
else. On pi 0.84–0.98 or with no classifier configured, the feature silently
stays off.

**Which model to pick** (2026-10-19 probe: 7 drafts across English,
German, and French, scored as verdicts that pass the shipped 0.6 confidence
gate):

- **Decision models are the quality picks**: `typesafe/jev-latest`,
  `liquid/d1`, and `inception/mercury-decide:free` each resolved 6/7 cases
  with 0.85–1.0 confidence. `mercury-decide` comes from the same vendor as
  the completion legs, and its `:free` OpenRouter variant costs nothing.
- **Local pi-reflex classifiers are private but currently too weak** for
  this task: `reflex/multilingual` and `reflex/english` resolved 0/7
  (choice and bool question forms alike; `reflex/typed-decisions` 2/7 on
  bools). Consults still run mechanically, they are just dropped by the
  `PI_GHOST_INTENT_CLASSIFIER_MIN_CONFIDENCE` gate, so the heuristic keeps
  deciding. Note the gate filters by confidence, not correctness: every
  reflex answer in the probe scored below 0.6, but a confidently wrong
  answer (≥ 0.6) would apply just as it would from any other model.
- **`auto` deliberately does not promote reflex**: a local model that never
  passes the gate would silently disable refinement for everyone with
  working decision-model credentials, so reflex stays in the
  anything-else tier.

## Turn-boundary warmup

The most expensive keystroke in a session is the first one after the agent
replies: the conversation just moved, so nothing in the completion cache can
serve, and you pay the full debounce + LLM round trip before any ghost
appears.

Turn-boundary warmup closes that gap. When the agent run fully settles
(pi's `agent_settled` — no retry, compaction, or queued continuation
follows), the extension looks at **your own recent messages**, derives your
habitual next-prompt openers (first one and two words, in your language and
casing), and prefetches completions for them into the cache and the
speculative ring — before you type anything:

- Pause exactly on a predicted opener → the ghost serves **synchronously**
  from cache: zero debounce, zero network.
- Type into a predicted completion → the ring's typeahead branch serves the
  remaining suffix the moment your prefix extends it.

On by default. To disable:

```bash
export PI_GHOST_TURN_PREFETCH=off
```

Design guarantees:

- **Best-effort and invisible** — warmup predictions never render, never
  emit shown metrics, and never touch provisional state; a wrong guess is
  simply an unreachable, TTL-bounded cache entry.
- **Bounded cost** — at most `PI_GHOST_TURN_PREFETCH_MAX` openers per turn
  (default 2), fired sequentially, each aborting after
  `PI_GHOST_TURN_PREFETCH_TIMEOUT_MS` (default 4000); repeated settles with
  no new exchange in between are fingerprint-deduped.
- **Aborted runs don't warm** (pi ≥ 1.1) — a cancelled run means you are
  about to type a redirect or correction, not your habitual opener, so an
  `aborted` settle skips warmup entirely. Older hosts cannot tell a
  cancelled run from a finished one and keep warming as before.
- **Quality parity** — prefetched text passes the same secret scan, rerank,
  and confidence gate as a live completion before it is cached.
- Register-partitioned keys mean a prefetched ghost can only serve under the
  register it was generated for.

Run `/ghost` to see `turnPrefetch=on max=2` in the status block. Works on
pi ≥ 0.84; on by default (the cost — one bounded LLM call per opener per
turn — is capped by the knobs below).

## Register routing

You are a developer typing a message **to** an AI coding agent — a request, an
instruction, a question. A code fill-in-the-middle model (Mercury Edit), fed
that message, tends to complete it like *code*: it writes the solution instead
of finishing your sentence. That's the wrong register.

Router mode fixes this by splitting the work between two models by
**completion register** (session 52). The register is a coarse classification
of *what kind of text* the user is composing, derived from the prompt intent
but language-independent:

| Register | Intents | Leg | System prompt |
| --- | --- | --- | --- |
| `code` | `command`, `path-ref`, unclassified | **FIM** (`mercury-edit-2`) | code/command/path completion |
| `agent-message` | `instruction`, `question` | **chat** (`mercury-3`) | "continue the user's message to an assistant, don't answer" |
| `general-prose` | `prose` | **chat** (`mercury-3`) | "continue the text naturally, same language/voice" — NO coding-agent framing |

The `general-prose` register (session 52) is the key fix for non-English and
non-instructional text: it gets a neutral persona that preserves the draft's
language and voice instead of reframing it as a task instruction. This means
German, French, Spanish, and other languages are completed in-language without
being distorted into coding-agent phrasing.

### Chat-leg abort-storm backoff (0.19)

While you type a natural-language prompt, every pause longer than the debounce
fires a chat request — and the next keystroke aborts it. Chat models answer in
~1–2s, so a mid-sentence request can never finish before you type on. The
result: the server bills a full request per word-pause, logs it as a client
disconnect, and no ghost is ever shown. One typed sentence could produce 16
requests with zero visible completions.

The backoff makes the chat leg stop firing into that wall:

- **Storm detection**: after 3 consecutive typing-aborted NL requests
  (`PI_GHOST_CHAT_ABORT_THRESHOLD`), storm mode engages.
- **Latency-aware pause**: the required idle before the next fire becomes the
  measured chat round-trip latency (EWMA × `PI_GHOST_CHAT_IDLE_FACTOR`),
  clamped to `PI_GHOST_CHAT_BACKOFF_MIN_MS`/`MAX_MS` — so requests only fire
  when the model can plausibly finish.
- **Escalation fallback**: before any latency sample exists, the pause doubles
  per extra abort (starting at 2× base), clamped to `[MIN, MAX]`.
- **Recovery**: any completed round-trip, a quiet period of
  `PI_GHOST_CHAT_STORM_DECAY_MS`, an Enter-commit, or a ghost accept
  (full/partial) resets the streak.

Only **typing-caused** aborts count toward the storm (0.19.1). Disposing the
editor, snooze, `/ghost off`, and Enter-commits are all excluded; the 10s
provider timeout is treated as a latency sample instead — a slow link adapts
the same way a slow model does. Storm state is visible in `/ghost diag` as
`chatBackoff=storm|ok (streak=… latencyEwma=…)`, and engage/clear transitions
appear in the debug log (`chat-backoff-storm`) when `PI_GHOST_DEBUG=1`.

The FIM/code register is untouched: its fast round-trips and short
completions make aborts cheap and the plain debounce appropriate. The backoff
floor never shortens a debounce — it only ever lengthens the chat-leg pause.

### Multilingual support (session 52)

Intent detection and context tokenization use Unicode-aware word segmentation
(`Intl.Segmenter`) instead of ASCII-only regexes. This means:

- German umlauts (möchte, ändern, über) count as complete words, not fragments
- French/Spanish diacritics (café, déjà, función) are preserved
- CJK text (Chinese/Japanese/Korean) gets dictionary-based word segmentation
- BM25 context selection works for non-English conversations
- No fixed English stopword list — IDF handles frequent terms per-language

Both legs default to the same vendor (Inception) and the same key, so a single
`INCEPTION_API_KEY` is enough. If the chat leg is unreachable or unconfigured,
the router degrades to FIM-only — identical to the default, so enabling it never
regresses.

```bash
# Single-vendor (Inception) — FIM for code, Mercury chat for instructions
PI_GHOST_PROVIDER=router INCEPTION_API_KEY=your-key pi ...

# Point the chat leg at a different model / endpoint / key
PI_GHOST_PROVIDER=router \
  INCEPTION_API_KEY=your-key \
  PI_GHOST_CHAT_MODEL=mercury-3 pi ...
```

See `PI_GHOST_CHAT_*` in the [Configuration](#configuration) table for the
chat-leg overrides.

If `PI_GHOST_CHAT_MODEL` is set to an id the endpoint doesn't recognise, the
chat leg is preflighted at startup and Pi shows a one-shot warning naming the
bad model — rather than silently falling back to the code model. In metrics,
router events are tagged by the served register (`ghost-router:chat` vs
`ghost-router:fim`), so `pi-ghost-bench` reports each leg's valid/bad-rate
separately.

## Race mode

Race mode fires requests to multiple providers simultaneously and uses the
first valid response. This reduces perceived latency when one provider is slow
or rate-limited.

```bash
# Race Groq against Cerebras
PI_GHOST_RACE_PROVIDERS=groq,cerebras pi ...

# Race Groq, Cerebras, and Mercury Edit (Inception Labs)
PI_GHOST_RACE_PROVIDERS=groq,cerebras,mercury-edit \
  INCEPTION_API_KEY=your-key pi ...
```

Race-specific environment variables:

| Variable | Default | Effect |
| --- | --- | --- |
| `PI_GHOST_RACE_PROVIDERS` | unset | Comma-separated list of providers to race (`groq`, `cerebras`, `local`, `mercury-edit`, `mercury-edit-2`, or any `KnownProvider`) |
| `PI_GHOST_RACE_PAUSE_MS` | 400 | Milliseconds before the second+ provider is unblocked |
| `PI_GHOST_RACE_COOLDOWN_MS` | 1500 | Quiet period after a winner before the next race starts |
| `PI_GHOST_RACE_CUTOFF_MS` | 700 | Maximum time to wait for any provider before giving up |
| `PI_GHOST_RACE_<NAME>_MODEL` | provider default | Override the model for a specific race member (e.g. `PI_GHOST_RACE_GROQ_MODEL`) |
| `PI_GHOST_RACE_<NAME>_API_KEY` | env var lookup | Override the API key for a specific race member |
| `PI_GHOST_RACE_<NAME>_BASE_URL` | provider default | Override the base URL for a specific race member |
| `INCEPTION_API_KEY` | unset | API key for Mercury Edit; used when `mercury-edit` or `mercury-edit-2` appears in the race list |
| `INCEPTION_BASE_URL` | `https://api.inceptionlabs.ai/v1` | Base URL override for Mercury Edit |

`<NAME>` in the per-member variables is the provider name uppercased with
non-alphanumeric characters replaced by `_` (e.g. `mercury-edit` →
`MERCURY_EDIT`).

## Metrics and debug logging

Enable structured JSONL logs for acceptance tracking and performance analysis:

```bash
PI_GHOST_METRICS=1 pi ...        # writes .pi/ghost-autocomplete/metrics.jsonl
PI_GHOST_DEBUG_LOG=1 pi ...      # writes .pi/ghost-autocomplete/debug.jsonl
```

Metrics records include: timestamp, request id, provider latencies, completion
mode (`deterministic` or `llm`), result (`produced`, `empty`, `rejected`,
`aborted`, `error`), rejection reason, completion register (`code`,
`agent-message`, `general-prose`), completion length, whether the ghost was
shown, and acceptance outcome (`accepted`, `dismissed`, `stale`, `expired`).
Raw prompt text and LLM output are never written to the metrics file. Both
files rotate at 10 MB.

When `result` is `empty`, the diagnosed cause is recorded in `emptyReason`:

| Reason | Meaning |
| --- | --- |
| `model-empty` | The model returned empty/whitespace-only text |
| `token-budget-exhausted` | The provider spent its entire `max_tokens` budget on internal reasoning before emitting visible content (Mercury 2 chat). Increase `PI_GHOST_CHAT_MAX_TOKENS` |
| `sanitizer-null` | The model returned content but the sanitizer rejected it (echo, filename-in-prose, markdown) |
| `response-schema-error` | The response parsed as JSON but lacked the expected `choices[].message` shape |
| `http-error` | Missing key, non-2xx, malformed JSON, fetch throw, or timeout |

`pi-ghost-bench` reports an `empty-cause` breakdown so you can distinguish a
model that declines from one that's starved for tokens.

## User profile

The extension keeps a long-lived JSONL store at
`.pi/ghost-autocomplete/profile.jsonl` to learn slash-command, path, and
trigram frequencies across sessions. The profile feeds three local-only
signals:

1. **Slash fast-path.** When the active line starts with `/`, the editor
   serves the most-typed extension as a ghost before the LLM is queried
   (mode `trie`, provider `profile-slash`). Press Right to accept.
2. **Profile-bias rerank (M3c hook).** A `profileBias` score plugs into
   the multi-candidate reranker. Wired but only takes effect when the
   provider returns >1 candidate.
3. **Bounded path boost.** Persistent path frequency adds a small
   capped boost to deterministic path/fuzzy ranking, capped so it cannot
   beat a fresh exact path-prefix match.

What's stored: paths (`src/foo.ts`), slash commands (`/review`),
trigrams from committed prompts, and acceptance records keyed by the
SHA-256 hash of the prefix. Raw prefixes and raw completions are
**never** written. Token tuples are tokenized and capped at 4 for
prefix tails / 8 for completions.

Sanitization runs before persistence: messages with API keys (`sk-…`,
`ghp_…`, JWT, etc.) or high-entropy tokens (Shannon > 4.0 bits/char,
length ≥ 20) are dropped entirely. The check is shared with the session
trie (`src/trie-sanitize.ts`).

Rotation: when the JSONL exceeds 2 MB, the store compacts in place —
sums weights per `(kind, key)`, halves records older than 90 days
(configurable via `PI_GHOST_PROFILE_DECAY_DAYS`), drops weights below
0.1. The original file is preserved as `profile.jsonl.1`.

Set `PI_GHOST_PROFILE=off` to disable capture + use entirely.

## Benchmark CLI

The `pi-ghost-bench` binary reads a metrics JSONL file and reports p50/p95
latency, acceptance rates, and whether PRD thresholds pass:

```bash
pi-ghost-bench                                    # reads .pi/ghost-autocomplete/metrics.jsonl
pi-ghost-bench path/to/metrics.jsonl              # explicit path
pi-ghost-bench --json                             # machine-readable output
```

Exit code is `0` when all thresholds pass, `1` when any fail, `2` on I/O error or when the file contains no records.

## How it works

- The extension registers a `GhostEditor` via `ctx.ui.setEditorComponent`.
  `GhostEditor` extends Pi's `CustomEditor` so all built-in app keybindings
  (escape, ctrl+d, model switching, slash-command popup) keep working.
- After every text change, a debounced request is sent through `pi-ai`'s
  `complete()` with an `AbortSignal` that is canceled on the next keystroke.
- Before calling the LLM, the editor checks the path index for a
  deterministic match. If the current token is a file path prefix or a fuzzy
  filename with a strong enough score, the deterministic result is shown
  immediately and the LLM call is suppressed.
- The path index is built asynchronously at session start via `git ls-files`
  from the repository root, capped at 20 000 files. High-confidence
  basename-prefix matches (score ≥85) preempt the LLM unconditionally; lower-
  confidence fuzzy matches only activate when an action word (`open`, `edit`,
  `read`, `show`, `view`, `find`, `check`, `look`, `load`, `import`, `include`,
  `see`, `run`) precedes the token. When candidates rank equally, direct and
  shallower paths win over nested ones (parity with Pi's own `@` file
  autocomplete ranking fix in 0.84.4).
- Recent agent messages come from `ctx.sessionManager.getBranch()` — only
  user/assistant text, no tool calls. Both providers truncate to a small,
  fixed payload so cloud requests don't ship the whole file.
- Path mentions are extracted from recent conversation messages (slashed
  paths, backticked path-like tokens, and bare filenames with known code
  extensions). These give a ranking boost (+10 exact, +7 basename) to
  deterministic path completions, so recently discussed files surface
  higher. On top of the scraped mentions, every successful `read` / `edit` /
  `write` tool call records its exact input path as a boost target for 10
  minutes — tool inputs are the precise strings the agent used, so files the
  agent just touched surface highest.
- For fuzzy filename matches where the typed token is not a prefix of the
  match, the editor enters **replace mode**: accepting the ghost replaces
  the typed token with the full matched path. A `→ ` prefix is rendered
  before the ghost text to signal this behaviour. Prefix-suffix matches
  still append as usual.
- `render()` searches for `CURSOR_MARKER` in the lines returned by
  `super.render()` and injects ANSI dim text directly after it. Pi's
  differential renderer redraws only the changed cells — no flicker, no
  full repaint.
- Right Arrow is intercepted only if a ghost is shown, the popup
  autocomplete is **not** open, and the cursor sits at the end of the text.
  We use `matchesKey(data, "right")` from `@earendil-works/pi-tui`, which
  handles legacy CSI/SS3 sequences and the Kitty keyboard protocol uniformly,
  so kitty / WezTerm / iTerm2 (Kitty-mode) all work. Otherwise the input
  passes through to `super.handleInput`.
- **Ctrl+Right** accepts the next *word* of the ghost (PRD §M3f). A "word"
  is a run of `\w+` characters, or a single non-word non-whitespace char
  with optional leading whitespace — VS Code's *Cursor Word Right* semantics.
  Repeated presses peel the ghost off one token at a time; the final press
  finalises the metric as `accepted-partial`.
- **Shift+↓** / **Shift+↑** cycle through ranked candidates when the provider
  returns more than one (e.g. `openai-compat` with `PI_GHOST_MERCURY_PARALLEL_N>1`
  and `PI_GHOST_TEMPERATURE>0`). Single-candidate sources (cache, trie,
  speculative warm, deterministic path, default LLM fire) treat both keys
  as no-ops. The selected candidate's index is logged as `candidateRank`
  on accept along with `cycledCount`.
- Visible-column math uses `string-width` + `strip-ansi`. Ghost text is
  clipped grapheme-aware to the remaining columns on the current visual line
  so wide CJK characters and emoji never overflow the row.
- While the LLM request is in flight, a provisional ghost (marked `◌ `)
  fills the gap after ~150 ms from local sources, tried in priority order:
  session trie → cross-session profile accepts → speculative-warm ring →
  verbatim last-message continuation → **offer candidates** → context
  bigrams. Offer candidates are structured choices extracted from recent
  assistant messages — numbered/bulleted list items, backticked code
  spans, quoted phrases — so when the agent ends with "1. commit this
  2. outcome verification" and you type `outc`, the ghost completes from
  the offer start even though your typed words don't appear in message
  order anywhere.

## Development

```bash
npm install
npm run typecheck   # tsc --noEmit against pi-tui / pi-coding-agent 1.2.0
npm test            # vitest, ~970 unit tests (requires Rust: builds native crate)
npm run build:dev   # plain tsc -> dist/ WITH source maps (local debugging)
```

The test suite and publish build delegate the crown-jewel logic (session
trie, reranker, sanitize) to a native Rust binary, so **Rust is a dev-time
dependency** ([install via rustup](https://rustup.rs)). End users do **not**
need Rust — they receive a prebuilt, platform-matched `.node` via
`optionalDependencies`.

CI runs all three on Node 20 and 22 (`.github/workflows/ci.yml`).

## Source protection (publish build)

This package ships **compiled + obfuscated** runtime, never raw TypeScript
and never source maps. A private GitHub repo does **not** make an npm
package private — anything in `files` is downloadable from the public
registry — so the publish pipeline hardens the runtime against casual
copying and reverse-engineering.

`npm run build` (and the auto-running `prepack` hook on `npm pack` /
`npm publish`) runs `scripts/build.mjs`, which:

1. builds the **native crown-jewel binary** (Rust, release, symbol-stripped)
   for the current platform and copies it to `dist/` as
   `pi-ghost-native.<triplet>.node` (skip with `PI_GHOST_SKIP_NATIVE=1`) — this
   is for **local dev runs only**; the main package tarball excludes it (see the
   `!dist/**/*.node` negation in `package.json` "files"), since end users get
   the binary from a per-platform optionalDependency, never bundled in `main`;
2. emits `.d.ts` declarations only (`tsconfig.build.json`) — no `.js`, no
   `.js.map`, no `.d.ts.map`;
3. **prunes internal declarations** — keeps only the `.d.ts` transitively
   reachable from the public entries (`index`, `bench`) and deletes the
   rest. Crown-jewel modules (`fast-path-engine`, `session-trie`, `reranker`,
   `provisional`, `regret-tracker`, `profile`, `edit-history-ring`, …) are
   internal and never re-exported, so their type declarations are removed
   from the tarball — they would otherwise be a symbol-level roadmap into
   the obfuscated bundle. A dangling-reference guard fails the build if
   pruning would break consumer type resolution;
4. bundles every entry point with **esbuild** into a single minified ESM
   chunk per entry (collapsing ~40 source modules into opaque bundles),
   keeping `@earendil-works/*` peers + `string-width`/`strip-ansi` external;
5. obfuscates each bundle with **javascript-obfuscator**;
6. re-adds exactly one `#!/usr/bin/env node` shebang to the two `bin` CLIs;
7. fails loudly if any `*.map` reaches `dist/`.

Obfuscation is tuned to be **latency-safe**: this is a ghost-text engine
where milliseconds matter, so control-flow flattening (the one transform
with real runtime cost) is OFF. What's ON: hexadecimal identifier renaming,
string-array encoding (base64) with two wrapper layers and hexadecimal
indexes, string splitting (5-char chunks), numeric-literal-to-expression
conversion (V8 constant-folds these at parse time, so zero runtime cost),
object-key transformation, and light dead-code injection. Public export
names are preserved so the pi extension loader and TS consumers keep working.

Tune the trade-off in `OBFUSCATOR_OPTIONS` inside `scripts/build.mjs`:

- **Smaller tarball / faster parse** → lower `deadCodeInjectionThreshold`
  (e.g. `0`) and `stringArrayThreshold` (e.g. `0.4`). `index.js` is ~575 kB
  with the hardened settings; dropping dead-code injection brings it back
  near the ~110 kB pre-obfuscation size.
- **Stronger protection** (test thoroughly first) → enable
  `controlFlowFlattening`, but expect a measurable latency hit on the
  autocomplete fast path.

What still ships readable: the `.d.ts` declarations for the **public API
only** (internal module declarations are pruned in step 2), plus the
JS wiring that calls into the native binary.

### Native crown jewels (tier-C hardening)

The tuned algorithm values — the reranker weights (`0.40/0.25/0.15/0.10/0.10`),
the prefix-aware `lengthPrior` curve, the confidence gates (`0.55/0.05`),
the session-trie lookup gates + typeahead + decay, and the secret/volatile
regex patterns — live **only** in a prebuilt, symbol-stripped native
`.node` binary (`native/`, Rust + napi-rs). No tuned literal appears in the
shipped JS: the wrapper fetches constants from the binary at load time, and
the JS `trie-sanitize.ts` / `session-trie.ts` / `reranker.ts` are thin
delegates. Feature extraction (merging, profile/edit-proximity closures)
stays in JS because it needs JS closures; the scoring math crosses the
boundary.

A reverser with the obfuscated bundle alone sees only *calls* into a native
binary, not the weights or the curves **as static literals**. Reading them out
of the binary requires disassembling the stripped Rust (Ghidra/IDA over
hours-to-days), not pasting into `obfuscator-io-deobfuscator` or asking an LLM.
This raises the cost of *static* recovery versus obfuscation alone — but see
the honest residual below: the scoring is recoverable *dynamically* regardless
of where the constants live.

**Distribution.** The binary ships per-platform as `optionalDependencies`
(`pi-ghost-autocomplete-<os>-<cpu>[-libc>`), following the @napi-rs
convention. End users on supported platforms need **no Rust toolchain** —
npm installs only the matching prebuild. Unsupported platforms fail at load
(by design: there is no recoverable JS fallback for the crown jewels). The
trade-off is a per-platform CI/release matrix (`.github/workflows/release.yml`).

**Honest residual.** This is not cryptographic protection, and two of the
"hidden" pieces are weaker than the binary boundary implies:

- **The reranker scoring is linear and black-box recoverable.** The score is a
  weighted sum of per-axis normalized features, and the public API returns the
  per-axis `scoreBreakdown` alongside the final `score`. Feeding ~5+ candidate
  sets and solving the resulting linear system recovers the weight vector and
  gate thresholds **without ever touching the binary** — no disassembly needed.
  Hiding the literals raises the bar against copy-paste, not against a
  competitor who probes the I/O.
- **The secret/volatile regexes are public patterns.** `sk-ant`, `ghp_`,
  `AKIA`, `AIza`, JWT shape, etc. are the same prefixes published by gitleaks /
  trufflehog. Moving them into the binary hides nothing of value; it's done for
  consistency, not secrecy.

What genuinely benefits from the boundary is the **session-trie lookup
algorithm shape** (gates, typeahead-delta, decay, volatile verdict) — that
structure is not trivially recoverable from I/O. Runtime instrumentation
(`--inspect`, `LD_PRELOAD`, `process.dlopen` hooks) can still observe
inputs/outputs of everything. What tier-C removes is the *automated,
minutes-to-recover static* attack surface that pure JS obfuscation leaves. The
real enforcement remains the **SSPL-1.0** license; the native boundary removes
the temptation for the honest-but-curious and raises the cost for a casual
competitor from "paste into a deobfuscator" to "learn Rust disassembly (or
write a solver)."

> **Platform support.** Prebuilt binaries ship for win32-x64-msvc,
> darwin-arm64, linux-x64-gnu, linux-arm64-gnu. **Intel Mac (darwin-x64)
> is not supported** — Apple stopped selling Intel Macs in 2020; Intel Mac
> users can run via Rosetta 2. **musl (Alpine) is not supported** — there is
> no JS fallback, so unsupported platforms fail at load by design.

Verify what a release contains at any time:

```bash
npm pack --dry-run
# expect: obfuscated dist/*.js + public dist/*.d.ts + README only.
# must show NO *.map, NO raw *.ts source (note: *.d.ts declarations are fine
# and expected), and NO *.node (the binary ships via optionalDependencies).
```

## Limitations

- No native Ollama provider in `pi-ai`; we configure an `openai-compat`
  model pointed at Ollama's `/v1` endpoint. Any OpenAI-compatible local
  server works the same way.
- Peer range `>=0.84` of `pi-tui` / `pi-coding-agent` / `pi-ai` /
  `pi-agent-core` (all under `@earendil-works`); dev/CI verify against the
  current pi release line (1.2.0; also typechecks clean against 1.3.0).
  Expect occasional follow-up bumps when the interfaces evolve.
- Predictions are shown only at the end of the buffer to avoid splitting
  lines mid-stream.
- The path index covers only tracked Git files. Untracked files are not
  indexed in v1.
- Three-provider racing may create elevated cost or rate-limit pressure at
  high typing speeds.

## License

SSPL-1.0 (Server Side Public License v1) — see [LICENSE](./LICENSE).
