pi-ghost-autocomplete 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/LICENSE +561 -0
  2. package/README.md +464 -331
  3. package/dist/bench-cli.js +1 -123
  4. package/dist/bench.d.ts +7 -3
  5. package/dist/bench.js +1 -522
  6. package/dist/config.d.ts +4 -1
  7. package/dist/context-aggregator.d.ts +4 -1
  8. package/dist/cycle-manager.d.ts +2 -21
  9. package/dist/entropy-stop.d.ts +4 -1
  10. package/dist/eval-dataset.d.ts +2 -13
  11. package/dist/fuzzy-matcher.d.ts +4 -1
  12. package/dist/ghost-editor.d.ts +11 -2
  13. package/dist/index.d.ts +4 -1
  14. package/dist/index.js +1 -289
  15. package/dist/intent.d.ts +179 -0
  16. package/dist/metrics.d.ts +4 -1
  17. package/dist/path-index.d.ts +4 -1
  18. package/dist/path-matcher.d.ts +4 -1
  19. package/dist/profile-store.d.ts +4 -1
  20. package/dist/profile.d.ts +2 -20
  21. package/dist/providers/cloud-provider.d.ts +4 -1
  22. package/dist/providers/local-provider.d.ts +4 -1
  23. package/dist/providers/openai-compat-provider.d.ts +24 -1
  24. package/dist/providers/race.d.ts +4 -1
  25. package/dist/providers/types.d.ts +13 -1
  26. package/dist/recent-paths.d.ts +4 -1
  27. package/dist/replay-cli.js +1 -166
  28. package/dist/replay.d.ts +2 -7
  29. package/package.json +90 -80
  30. package/dist/bench-cli.d.ts +0 -3
  31. package/dist/bench-cli.d.ts.map +0 -1
  32. package/dist/bench-cli.js.map +0 -1
  33. package/dist/bench.d.ts.map +0 -1
  34. package/dist/bench.js.map +0 -1
  35. package/dist/cache.d.ts +0 -124
  36. package/dist/cache.d.ts.map +0 -1
  37. package/dist/cache.js +0 -173
  38. package/dist/cache.js.map +0 -1
  39. package/dist/candidate-rerank.d.ts +0 -57
  40. package/dist/candidate-rerank.d.ts.map +0 -1
  41. package/dist/candidate-rerank.js +0 -78
  42. package/dist/candidate-rerank.js.map +0 -1
  43. package/dist/config.d.ts.map +0 -1
  44. package/dist/config.js +0 -317
  45. package/dist/config.js.map +0 -1
  46. package/dist/context-aggregator.d.ts.map +0 -1
  47. package/dist/context-aggregator.js +0 -87
  48. package/dist/context-aggregator.js.map +0 -1
  49. package/dist/context-selector.d.ts +0 -128
  50. package/dist/context-selector.d.ts.map +0 -1
  51. package/dist/context-selector.js +0 -300
  52. package/dist/context-selector.js.map +0 -1
  53. package/dist/cycle-manager.d.ts.map +0 -1
  54. package/dist/cycle-manager.js +0 -168
  55. package/dist/cycle-manager.js.map +0 -1
  56. package/dist/edit-history-ring.d.ts +0 -148
  57. package/dist/edit-history-ring.d.ts.map +0 -1
  58. package/dist/edit-history-ring.js +0 -262
  59. package/dist/edit-history-ring.js.map +0 -1
  60. package/dist/entropy-stop.d.ts.map +0 -1
  61. package/dist/entropy-stop.js +0 -141
  62. package/dist/entropy-stop.js.map +0 -1
  63. package/dist/eval-dataset.d.ts.map +0 -1
  64. package/dist/eval-dataset.js +0 -127
  65. package/dist/eval-dataset.js.map +0 -1
  66. package/dist/fast-path-engine.d.ts +0 -286
  67. package/dist/fast-path-engine.d.ts.map +0 -1
  68. package/dist/fast-path-engine.js +0 -341
  69. package/dist/fast-path-engine.js.map +0 -1
  70. package/dist/fuzzy-matcher.d.ts.map +0 -1
  71. package/dist/fuzzy-matcher.js +0 -162
  72. package/dist/fuzzy-matcher.js.map +0 -1
  73. package/dist/ghost-editor.d.ts.map +0 -1
  74. package/dist/ghost-editor.js +0 -1491
  75. package/dist/ghost-editor.js.map +0 -1
  76. package/dist/index.d.ts.map +0 -1
  77. package/dist/index.js.map +0 -1
  78. package/dist/keystroke-timer.d.ts +0 -59
  79. package/dist/keystroke-timer.d.ts.map +0 -1
  80. package/dist/keystroke-timer.js +0 -94
  81. package/dist/keystroke-timer.js.map +0 -1
  82. package/dist/metrics-builder.d.ts +0 -71
  83. package/dist/metrics-builder.d.ts.map +0 -1
  84. package/dist/metrics-builder.js +0 -71
  85. package/dist/metrics-builder.js.map +0 -1
  86. package/dist/metrics.d.ts.map +0 -1
  87. package/dist/metrics.js +0 -212
  88. package/dist/metrics.js.map +0 -1
  89. package/dist/path-index.d.ts.map +0 -1
  90. package/dist/path-index.js +0 -148
  91. package/dist/path-index.js.map +0 -1
  92. package/dist/path-matcher.d.ts.map +0 -1
  93. package/dist/path-matcher.js +0 -88
  94. package/dist/path-matcher.js.map +0 -1
  95. package/dist/profile-store.d.ts.map +0 -1
  96. package/dist/profile-store.js +0 -199
  97. package/dist/profile-store.js.map +0 -1
  98. package/dist/profile.d.ts.map +0 -1
  99. package/dist/profile.js +0 -434
  100. package/dist/profile.js.map +0 -1
  101. package/dist/providers/cloud-provider.d.ts.map +0 -1
  102. package/dist/providers/cloud-provider.js +0 -109
  103. package/dist/providers/cloud-provider.js.map +0 -1
  104. package/dist/providers/local-provider.d.ts.map +0 -1
  105. package/dist/providers/local-provider.js +0 -48
  106. package/dist/providers/local-provider.js.map +0 -1
  107. package/dist/providers/openai-compat-provider.d.ts.map +0 -1
  108. package/dist/providers/openai-compat-provider.js +0 -215
  109. package/dist/providers/openai-compat-provider.js.map +0 -1
  110. package/dist/providers/openai-completions-runner.d.ts +0 -52
  111. package/dist/providers/openai-completions-runner.d.ts.map +0 -1
  112. package/dist/providers/openai-completions-runner.js +0 -141
  113. package/dist/providers/openai-completions-runner.js.map +0 -1
  114. package/dist/providers/race.d.ts.map +0 -1
  115. package/dist/providers/race.js +0 -441
  116. package/dist/providers/race.js.map +0 -1
  117. package/dist/providers/truncate.d.ts +0 -7
  118. package/dist/providers/truncate.d.ts.map +0 -1
  119. package/dist/providers/truncate.js +0 -57
  120. package/dist/providers/truncate.js.map +0 -1
  121. package/dist/providers/types.d.ts.map +0 -1
  122. package/dist/providers/types.js +0 -60
  123. package/dist/providers/types.js.map +0 -1
  124. package/dist/provisional-manager.d.ts +0 -93
  125. package/dist/provisional-manager.d.ts.map +0 -1
  126. package/dist/provisional-manager.js +0 -98
  127. package/dist/provisional-manager.js.map +0 -1
  128. package/dist/provisional.d.ts +0 -150
  129. package/dist/provisional.d.ts.map +0 -1
  130. package/dist/provisional.js +0 -238
  131. package/dist/provisional.js.map +0 -1
  132. package/dist/recent-paths.d.ts.map +0 -1
  133. package/dist/recent-paths.js +0 -114
  134. package/dist/recent-paths.js.map +0 -1
  135. package/dist/regret-tracker.d.ts +0 -84
  136. package/dist/regret-tracker.d.ts.map +0 -1
  137. package/dist/regret-tracker.js +0 -145
  138. package/dist/regret-tracker.js.map +0 -1
  139. package/dist/replay-cli.d.ts +0 -13
  140. package/dist/replay-cli.d.ts.map +0 -1
  141. package/dist/replay-cli.js.map +0 -1
  142. package/dist/replay.d.ts.map +0 -1
  143. package/dist/replay.js +0 -225
  144. package/dist/replay.js.map +0 -1
  145. package/dist/reranker.d.ts +0 -181
  146. package/dist/reranker.d.ts.map +0 -1
  147. package/dist/reranker.js +0 -324
  148. package/dist/reranker.js.map +0 -1
  149. package/dist/session-trie.d.ts +0 -103
  150. package/dist/session-trie.d.ts.map +0 -1
  151. package/dist/session-trie.js +0 -280
  152. package/dist/session-trie.js.map +0 -1
  153. package/dist/speculative.d.ts +0 -74
  154. package/dist/speculative.d.ts.map +0 -1
  155. package/dist/speculative.js +0 -107
  156. package/dist/speculative.js.map +0 -1
  157. package/dist/tool-summarize.d.ts +0 -40
  158. package/dist/tool-summarize.d.ts.map +0 -1
  159. package/dist/tool-summarize.js +0 -245
  160. package/dist/tool-summarize.js.map +0 -1
  161. package/dist/trie-sanitize.d.ts +0 -22
  162. package/dist/trie-sanitize.d.ts.map +0 -1
  163. package/dist/trie-sanitize.js +0 -142
  164. package/dist/trie-sanitize.js.map +0 -1
  165. package/dist/versions.d.ts +0 -23
  166. package/dist/versions.d.ts.map +0 -1
  167. package/dist/versions.js +0 -31
  168. package/dist/versions.js.map +0 -1
package/README.md CHANGED
@@ -1,331 +1,464 @@
1
- # pi-ghost-autocomplete
2
-
3
- Inline ghost-text autocomplete for the [Pi coding agent](https://pi.dev/) (`@earendil-works/pi-coding-agent`).
4
-
5
- While you type at the Pi prompt, an LLM predicts the most likely continuation
6
- of the current line and renders it as dim grey "ghost text" right of the
7
- cursor. Press **Right Arrow** to accept. The cursor never moves until you do,
8
- the existing slash-command popup keeps **Tab**, and a slow or unreachable
9
- backend silently disables itself rather than spamming the chat.
10
-
11
- Four provider modes ship in the same package:
12
-
13
- | Mode | Backend | Default debounce | Default model |
14
- | --- | --- | --- | --- |
15
- | `cloud` | Groq / Cerebras / OpenRouter via `@earendil-works/pi-ai` | 150 ms | `groq/llama-3.1-8b-instant` |
16
- | `local` | Ollama / vLLM (OpenAI-compatible) | 400 ms | `qwen2.5-coder:1.5b` on `http://localhost:11434` |
17
- | `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` |
18
- | `race` | Multiple providers in parallel; first valid response wins | 400 ms | per-member defaults |
19
-
20
- ## Getting started
21
-
22
- ### 1. Get a Mercury Edit 2 API key
23
-
24
- 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.
25
-
26
- 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:
27
-
28
- ```bash
29
- # ~/.bashrc or ~/.zshrc
30
- export INCEPTION_API_KEY="your-key"
31
- ```
32
-
33
- ```powershell
34
- # PowerShell $PROFILE
35
- $env:INCEPTION_API_KEY = "your-key"
36
- ```
37
-
38
- Or pass it inline for a single session:
39
-
40
- ```bash
41
- INCEPTION_API_KEY="your-key" pi
42
- ```
43
-
44
- ```powershell
45
- $env:INCEPTION_API_KEY = "your-key"; pi
46
- ```
47
-
48
- To use a different provider instead (Groq, Cerebras, a local model, …), see [Configuration](#configuration).
49
-
50
- ### 2. Install the extension
51
-
52
- ```bash
53
- pi install npm:pi-ghost-autocomplete
54
- ```
55
-
56
- This registers the extension globally. Pi picks it up automatically on the next launch. To install it only for a single project, add `-l`:
57
-
58
- ```bash
59
- pi install npm:pi-ghost-autocomplete -l # writes to .pi/ in the current directory
60
- ```
61
-
62
- ### 3. Launch Pi and try it
63
-
64
- ```bash
65
- pi
66
- ```
67
-
68
- 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.
69
-
70
- Run `/ghost` to confirm the extension loaded and see the active configuration:
71
-
72
- ```
73
- Pi Ghost: enabled
74
- minChars=3
75
- maxLineLength=240
76
- mode=openai-compat
77
- provider=mercury-edit
78
- baseUrl=https://api.inceptionlabs.ai/v1
79
- model=mercury-edit-2
80
- apiKey=4e24***
81
- debounceMs=150
82
- maxTokens=64
83
- maxRecentMessages=4
84
- ```
85
-
86
- ### 4. (Optional) Load the extension without installing
87
-
88
- Pass `-e` to load the package for a single session without a permanent install:
89
-
90
- ```bash
91
- pi -e npm:pi-ghost-autocomplete
92
- ```
93
-
94
- ## Install
95
-
96
- ```bash
97
- npm install pi-ghost-autocomplete
98
- ```
99
-
100
- ## Configuration
101
-
102
- All knobs are environment variables — no extra config file needed:
103
-
104
- | Variable | Default | Effect |
105
- | --- | --- | --- |
106
- | `PI_GHOST_DISABLED` | unset | Set to `1` to disable on launch |
107
- | `PI_GHOST_PROVIDER` | unset (Mercury Edit 2) | `cloud` or `local` — set explicitly to switch away from Mercury Edit 2 (ignored when `PI_GHOST_RACE_PROVIDERS` is set) |
108
- | `PI_GHOST_PROVIDER_NAME` | `groq` | Cloud only (`PI_GHOST_PROVIDER=cloud`): any `KnownProvider` from `pi-ai` (`groq`, `cerebras`, `openrouter`, …) |
109
- | `PI_GHOST_MODEL` | provider-specific | Model id, e.g. `llama-3.1-8b-instant`, `qwen2.5-coder:1.5b` |
110
- | `PI_GHOST_API_KEY` | env var lookup | Cloud only: explicit API key (else `pi-ai` env lookup is used) |
111
- | `PI_GHOST_BASE_URL` | `http://localhost:11434` | Local only: Ollama / vLLM base URL |
112
- | `PI_GHOST_DEBOUNCE_MS` | 150 (cloud) / 400 (local) | Inputs are debounced this long before a request is sent |
113
- | `PI_GHOST_MAX_TOKENS` | 64 (cloud) / 48 (local) | Cap on completion tokens |
114
- | `PI_GHOST_RECENT` | 4 (cloud) / 2 (local) | Number of recent user/assistant messages bundled as context |
115
- | `PI_GHOST_MIN_CHARS` | 3 | Minimum draft length before a prediction fires |
116
- | `PI_GHOST_MAX_LINE` | 240 | Predictions skipped if the current line exceeds this length |
117
- | `PI_GHOST_TEMPERATURE` | `0` | Sampling temperature [0, 2]. `0` is best-effort deterministic; see note below |
118
- | `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 |
119
- | `PI_GHOST_CONTEXT_MAX_SCAN` | `200` | M6b: cap on the candidate pool scanned for relevance ranking |
120
- | `PI_GHOST_CONTEXT_MAX_SELECTED` | `8` | M6b: max number of selected context entries |
121
- | `PI_GHOST_CONTEXT_MAX_CHARS` | `4000` | M6b: total char budget for the selected context window |
122
- | `PI_GHOST_CONTEXT_BM25_WEIGHT` | `0.3` | M6b: weight of BM25 content score vs recency (0=pure recency, 1=pure relevance) |
123
- | `PI_GHOST_CONTEXT_TOOL_MAX` | `3` | M6c: max number of tool entries to include in selected context (0 disables tools entirely) |
124
- | `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 |
125
- | `PI_GHOST_PROFILE` | unset | Set to `off` to disable persistent profile capture + reranking. Default: enabled. Profile is local-only — see [Profile](#user-profile) below |
126
- | `PI_GHOST_PROFILE_DECAY_DAYS` | `90` | Days before an unused profile record's weight halves during compaction |
127
- | `PI_GHOST_PROVISIONAL` | unset | Set to `off` to disable the 150 ms provisional fallback. Default: enabled |
128
- | `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 |
129
- | `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 |
130
- | `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 |
131
- | `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 |
132
- | `PI_GHOST_REGRET_WINDOW_MS` | `3000` | After accept, window in ms during which a >50% deletion of inserted text flags the record `acceptRegret: true` |
133
- | `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 |
134
- | `PI_GHOST_METRICS` | unset | Set to `1` to write metrics to `.pi/ghost-autocomplete/metrics.jsonl` |
135
- | `PI_GHOST_DEBUG_LOG` | unset | Set to `1` to write debug records to `.pi/ghost-autocomplete/debug.jsonl` |
136
-
137
- ### Note on `temperature=0`
138
-
139
- `temperature=0` is the default and asks the provider for greedy decoding so
140
- the same prefix produces the same completion. This is **best-effort, not
141
- guaranteed**: vendors batch requests across users and kernel scheduling
142
- differences can produce different greedy paths from one call to the next.
143
- The session's in-memory completion cache is the real determinism guarantee
144
- — retyping the same prefix in the same context will reuse the previous
145
- ghost rather than re-querying. Raise temperature only if you want
146
- cache-miss diversity (e.g. `PI_GHOST_TEMPERATURE=0.5`).
147
-
148
- Run `/ghost` inside Pi to see the effective config; `/ghost on` and
149
- `/ghost off` toggle the feature for the current session.
150
-
151
- ## Race mode
152
-
153
- Race mode fires requests to multiple providers simultaneously and uses the
154
- first valid response. This reduces perceived latency when one provider is slow
155
- or rate-limited.
156
-
157
- ```bash
158
- # Race Groq against Cerebras
159
- PI_GHOST_RACE_PROVIDERS=groq,cerebras pi ...
160
-
161
- # Race Groq, Cerebras, and Mercury Edit (Inception Labs)
162
- PI_GHOST_RACE_PROVIDERS=groq,cerebras,mercury-edit \
163
- INCEPTION_API_KEY=your-key pi ...
164
- ```
165
-
166
- Race-specific environment variables:
167
-
168
- | Variable | Default | Effect |
169
- | --- | --- | --- |
170
- | `PI_GHOST_RACE_PROVIDERS` | unset | Comma-separated list of providers to race (`groq`, `cerebras`, `local`, `mercury-edit`, `mercury-edit-2`, or any `KnownProvider`) |
171
- | `PI_GHOST_RACE_PAUSE_MS` | 400 | Milliseconds before the second+ provider is unblocked |
172
- | `PI_GHOST_RACE_COOLDOWN_MS` | 1500 | Quiet period after a winner before the next race starts |
173
- | `PI_GHOST_RACE_CUTOFF_MS` | 700 | Maximum time to wait for any provider before giving up |
174
- | `PI_GHOST_RACE_<NAME>_MODEL` | provider default | Override the model for a specific race member (e.g. `PI_GHOST_RACE_GROQ_MODEL`) |
175
- | `PI_GHOST_RACE_<NAME>_API_KEY` | env var lookup | Override the API key for a specific race member |
176
- | `PI_GHOST_RACE_<NAME>_BASE_URL` | provider default | Override the base URL for a specific race member |
177
- | `INCEPTION_API_KEY` | unset | API key for Mercury Edit; used when `mercury-edit` or `mercury-edit-2` appears in the race list |
178
- | `INCEPTION_BASE_URL` | `https://api.inceptionlabs.ai/v1` | Base URL override for Mercury Edit |
179
-
180
- `<NAME>` in the per-member variables is the provider name uppercased with
181
- non-alphanumeric characters replaced by `_` (e.g. `mercury-edit` →
182
- `MERCURY_EDIT`).
183
-
184
- ## Metrics and debug logging
185
-
186
- Enable structured JSONL logs for acceptance tracking and performance analysis:
187
-
188
- ```bash
189
- PI_GHOST_METRICS=1 pi ... # writes .pi/ghost-autocomplete/metrics.jsonl
190
- PI_GHOST_DEBUG_LOG=1 pi ... # writes .pi/ghost-autocomplete/debug.jsonl
191
- ```
192
-
193
- Metrics records include: timestamp, request id, provider latencies, completion
194
- mode (`deterministic` or `llm`), result (`produced`, `empty`, `rejected`,
195
- `aborted`, `error`), rejection reason, completion length, whether the ghost was
196
- shown, and acceptance outcome (`accepted`, `dismissed`, `stale`, `expired`).
197
- Raw prompt text and LLM output are never written to the metrics file. Both
198
- files rotate at 10 MB.
199
-
200
- ## User profile
201
-
202
- The extension keeps a long-lived JSONL store at
203
- `.pi/ghost-autocomplete/profile.jsonl` to learn slash-command, path, and
204
- trigram frequencies across sessions. The profile feeds three local-only
205
- signals:
206
-
207
- 1. **Slash fast-path.** When the active line starts with `/`, the editor
208
- serves the most-typed extension as a ghost before the LLM is queried
209
- (mode `trie`, provider `profile-slash`). Press Right to accept.
210
- 2. **Profile-bias rerank (M3c hook).** A `profileBias` score plugs into
211
- the multi-candidate reranker. Wired but only takes effect when the
212
- provider returns >1 candidate.
213
- 3. **Bounded path boost.** Persistent path frequency adds a small
214
- capped boost to deterministic path/fuzzy ranking, capped so it cannot
215
- beat a fresh exact path-prefix match.
216
-
217
- What's stored: paths (`src/foo.ts`), slash commands (`/review`),
218
- trigrams from committed prompts, and acceptance records keyed by the
219
- SHA-256 hash of the prefix. Raw prefixes and raw completions are
220
- **never** written. Token tuples are tokenized and capped at 4 for
221
- prefix tails / 8 for completions.
222
-
223
- Sanitization runs before persistence: messages with API keys (`sk-…`,
224
- `ghp_…`, JWT, etc.) or high-entropy tokens (Shannon > 4.0 bits/char,
225
- length ≥ 20) are dropped entirely. The check is shared with the session
226
- trie (`src/trie-sanitize.ts`).
227
-
228
- Rotation: when the JSONL exceeds 2 MB, the store compacts in place —
229
- sums weights per `(kind, key)`, halves records older than 90 days
230
- (configurable via `PI_GHOST_PROFILE_DECAY_DAYS`), drops weights below
231
- 0.1. The original file is preserved as `profile.jsonl.1`.
232
-
233
- Set `PI_GHOST_PROFILE=off` to disable capture + use entirely.
234
-
235
- ## Benchmark CLI
236
-
237
- The `pi-ghost-bench` binary reads a metrics JSONL file and reports p50/p95
238
- latency, acceptance rates, and whether PRD thresholds pass:
239
-
240
- ```bash
241
- pi-ghost-bench # reads .pi/ghost-autocomplete/metrics.jsonl
242
- pi-ghost-bench path/to/metrics.jsonl # explicit path
243
- pi-ghost-bench --json # machine-readable output
244
- ```
245
-
246
- Exit code is `0` when all thresholds pass, `1` when any fail, `2` on I/O error or when the file contains no records.
247
-
248
- ## How it works
249
-
250
- - The extension registers a `GhostEditor` via `ctx.ui.setEditorComponent`.
251
- `GhostEditor` extends Pi's `CustomEditor` so all built-in app keybindings
252
- (escape, ctrl+d, model switching, slash-command popup) keep working.
253
- - After every text change, a debounced request is sent through `pi-ai`'s
254
- `complete()` with an `AbortSignal` that is canceled on the next keystroke.
255
- - Before calling the LLM, the editor checks the path index for a
256
- deterministic match. If the current token is a file path prefix or a fuzzy
257
- filename with a strong enough score, the deterministic result is shown
258
- immediately and the LLM call is suppressed.
259
- - The path index is built asynchronously at session start via `git ls-files`
260
- from the repository root, capped at 20 000 files. High-confidence
261
- basename-prefix matches (score ≥85) preempt the LLM unconditionally; lower-
262
- confidence fuzzy matches only activate when an action word (`open`, `edit`,
263
- `read`, `show`, `view`, `find`, `check`, `look`, `load`, `import`, `include`,
264
- `see`, `run`) precedes the token.
265
- - Recent agent messages come from `ctx.sessionManager.getBranch()` — only
266
- user/assistant text, no tool calls. Both providers truncate to a small,
267
- fixed payload so cloud requests don't ship the whole file.
268
- - Path mentions are extracted from recent conversation messages (slashed
269
- paths, backticked path-like tokens, and bare filenames with known code
270
- extensions). These give a ranking boost (+10 exact, +7 basename) to
271
- deterministic path completions, so recently discussed files surface
272
- higher.
273
- - For fuzzy filename matches where the typed token is not a prefix of the
274
- match, the editor enters **replace mode**: accepting the ghost replaces
275
- the typed token with the full matched path. A `→ ` prefix is rendered
276
- before the ghost text to signal this behaviour. Prefix-suffix matches
277
- still append as usual.
278
- - `render()` searches for `CURSOR_MARKER` in the lines returned by
279
- `super.render()` and injects ANSI dim text directly after it. Pi's
280
- differential renderer redraws only the changed cells — no flicker, no
281
- full repaint.
282
- - Right Arrow is intercepted only if a ghost is shown, the popup
283
- autocomplete is **not** open, and the cursor sits at the end of the text.
284
- We use `matchesKey(data, "right")` from `@earendil-works/pi-tui`, which
285
- handles legacy CSI/SS3 sequences and the Kitty keyboard protocol uniformly,
286
- so kitty / WezTerm / iTerm2 (Kitty-mode) all work. Otherwise the input
287
- passes through to `super.handleInput`.
288
- - **Ctrl+Right** accepts the next *word* of the ghost (PRD §M3f). A "word"
289
- is a run of `\w+` characters, or a single non-word non-whitespace char
290
- with optional leading whitespace — VS Code's *Cursor Word Right* semantics.
291
- Repeated presses peel the ghost off one token at a time; the final press
292
- finalises the metric as `accepted-partial`.
293
- - **Alt+]** / **Alt+[** cycle through ranked candidates when the provider
294
- returns more than one (e.g. `openai-compat` with `PI_GHOST_MERCURY_PARALLEL_N>1`
295
- and `PI_GHOST_TEMPERATURE>0`). Single-candidate sources (cache, trie,
296
- speculative warm, deterministic path, default LLM fire) treat both keys
297
- as no-ops. The selected candidate's index is logged as `candidateRank`
298
- on accept along with `cycledCount`.
299
- - Visible-column math uses `string-width` + `strip-ansi`. Ghost text is
300
- clipped grapheme-aware to the remaining columns on the current visual line
301
- so wide CJK characters and emoji never overflow the row.
302
-
303
- ## Development
304
-
305
- ```bash
306
- npm install
307
- npm run typecheck # tsc --noEmit against pi-tui / pi-coding-agent v0.74
308
- npm test # vitest, ~170 unit tests
309
- npm run build # emits dist/
310
- ```
311
-
312
- CI runs all three on Node 20 and 22 (`.github/workflows/ci.yml`).
313
-
314
- ## Limitations
315
-
316
- - No native Ollama provider in `pi-ai`; we configure an `openai-compat`
317
- model pointed at Ollama's `/v1` endpoint. Any OpenAI-compatible local
318
- server works the same way.
319
- - Pin to `^0.74` of `pi-tui` / `pi-coding-agent` / `pi-ai` /
320
- `pi-agent-core` (all under `@earendil-works`). The relevant interfaces are
321
- pre-1.0; expect occasional follow-up bumps.
322
- - Predictions are shown only at the end of the buffer to avoid splitting
323
- lines mid-stream.
324
- - The path index covers only tracked Git files. Untracked files are not
325
- indexed in v1.
326
- - Three-provider racing may create elevated cost or rate-limit pressure at
327
- high typing speeds.
328
-
329
- ## License
330
-
331
- MIT
1
+ # pi-ghost-autocomplete
2
+
3
+ Inline ghost-text autocomplete for the [Pi coding agent](https://pi.dev/) (`@earendil-works/pi-coding-agent`).
4
+
5
+ While you type at the Pi prompt, an LLM predicts the most likely continuation
6
+ of the current line and renders it as dim grey "ghost text" right of the
7
+ cursor. Press **Right Arrow** to accept. The cursor never moves until you do,
8
+ the existing slash-command popup keeps **Tab**, and a slow or unreachable
9
+ backend silently disables itself rather than spamming the chat.
10
+
11
+ Four provider modes ship in the same package:
12
+
13
+ | Mode | Backend | Default debounce | Default model |
14
+ | --- | --- | --- | --- |
15
+ | `cloud` | Groq / Cerebras / OpenRouter via `@earendil-works/pi-ai` | 150 ms | `groq/llama-3.1-8b-instant` |
16
+ | `local` | Ollama / vLLM (OpenAI-compatible) | 400 ms | `qwen2.5-coder:1.5b` on `http://localhost:11434` |
17
+ | `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` |
18
+ | `race` | Multiple providers in parallel; first valid response wins | 400 ms | per-member defaults |
19
+
20
+ ## Getting started
21
+
22
+ ### 1. Get a Mercury Edit 2 API key
23
+
24
+ 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.
25
+
26
+ 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:
27
+
28
+ ```bash
29
+ # ~/.bashrc or ~/.zshrc
30
+ export INCEPTION_API_KEY="your-key"
31
+ ```
32
+
33
+ ```powershell
34
+ # PowerShell $PROFILE
35
+ $env:INCEPTION_API_KEY = "your-key"
36
+ ```
37
+
38
+ Or pass it inline for a single session:
39
+
40
+ ```bash
41
+ INCEPTION_API_KEY="your-key" pi
42
+ ```
43
+
44
+ ```powershell
45
+ $env:INCEPTION_API_KEY = "your-key"; pi
46
+ ```
47
+
48
+ To use a different provider instead (Groq, Cerebras, a local model, …), see [Configuration](#configuration).
49
+
50
+ ### 2. Install the extension
51
+
52
+ ```bash
53
+ pi install npm:pi-ghost-autocomplete
54
+ ```
55
+
56
+ This registers the extension globally. Pi picks it up automatically on the next launch. To install it only for a single project, add `-l`:
57
+
58
+ ```bash
59
+ pi install npm:pi-ghost-autocomplete -l # writes to .pi/ in the current directory
60
+ ```
61
+
62
+ ### 3. Launch Pi and try it
63
+
64
+ ```bash
65
+ pi
66
+ ```
67
+
68
+ 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.
69
+
70
+ Run `/ghost` to confirm the extension loaded and see the active configuration:
71
+
72
+ ```
73
+ Pi Ghost: enabled
74
+ minChars=3
75
+ maxLineLength=240
76
+ mode=openai-compat
77
+ provider=mercury-edit
78
+ baseUrl=https://api.inceptionlabs.ai/v1
79
+ model=mercury-edit-2
80
+ apiKey=4e24***
81
+ debounceMs=150
82
+ maxTokens=64
83
+ maxRecentMessages=4
84
+ ```
85
+
86
+ ### 4. (Optional) Load the extension without installing
87
+
88
+ Pass `-e` to load the package for a single session without a permanent install:
89
+
90
+ ```bash
91
+ pi -e npm:pi-ghost-autocomplete
92
+ ```
93
+
94
+ ## Install
95
+
96
+ ```bash
97
+ npm install pi-ghost-autocomplete
98
+ ```
99
+
100
+ ## Configuration
101
+
102
+ All knobs are environment variables — no extra config file needed:
103
+
104
+ | Variable | Default | Effect |
105
+ | --- | --- | --- |
106
+ | `PI_GHOST_DISABLED` | unset | Set to `1` to disable on launch |
107
+ | `PI_GHOST_PROVIDER` | unset (Mercury Edit 2) | `cloud` or `local` — set explicitly to switch away from Mercury Edit 2 (ignored when `PI_GHOST_RACE_PROVIDERS` is set) |
108
+ | `PI_GHOST_PROVIDER_NAME` | `groq` | Cloud only (`PI_GHOST_PROVIDER=cloud`): any `KnownProvider` from `pi-ai` (`groq`, `cerebras`, `openrouter`, …) |
109
+ | `PI_GHOST_MODEL` | provider-specific | Model id, e.g. `llama-3.1-8b-instant`, `qwen2.5-coder:1.5b` |
110
+ | `PI_GHOST_API_KEY` | env var lookup | Cloud only: explicit API key (else `pi-ai` env lookup is used) |
111
+ | `PI_GHOST_BASE_URL` | `http://localhost:11434` | Local only: Ollama / vLLM base URL |
112
+ | `PI_GHOST_DEBOUNCE_MS` | 150 (cloud) / 400 (local) | Inputs are debounced this long before a request is sent |
113
+ | `PI_GHOST_MAX_TOKENS` | 64 (cloud) / 48 (local) | Cap on completion tokens |
114
+ | `PI_GHOST_RECENT` | 4 (cloud) / 2 (local) | Number of recent user/assistant messages bundled as context |
115
+ | `PI_GHOST_MIN_CHARS` | 3 | Minimum draft length before a prediction fires |
116
+ | `PI_GHOST_MAX_LINE` | 240 | Predictions skipped if the current line exceeds this length |
117
+ | `PI_GHOST_TEMPERATURE` | `0` | Sampling temperature [0, 2]. `0` is best-effort deterministic; see note below |
118
+ | `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 |
119
+ | `PI_GHOST_CONTEXT_MAX_SCAN` | `200` | M6b: cap on the candidate pool scanned for relevance ranking |
120
+ | `PI_GHOST_CONTEXT_MAX_SELECTED` | `8` | M6b: max number of selected context entries |
121
+ | `PI_GHOST_CONTEXT_MAX_CHARS` | `4000` | M6b: total char budget for the selected context window |
122
+ | `PI_GHOST_CONTEXT_BM25_WEIGHT` | `0.3` | M6b: weight of BM25 content score vs recency (0=pure recency, 1=pure relevance) |
123
+ | `PI_GHOST_CONTEXT_TOOL_MAX` | `3` | M6c: max number of tool entries to include in selected context (0 disables tools entirely) |
124
+ | `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) |
125
+ | `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 |
126
+ | `PI_GHOST_PROFILE` | unset | Set to `off` to disable persistent profile capture + reranking. Default: enabled. Profile is local-only — see [Profile](#user-profile) below |
127
+ | `PI_GHOST_PROFILE_DECAY_DAYS` | `90` | Days before an unused profile record's weight halves during compaction |
128
+ | `PI_GHOST_PROVISIONAL` | unset | Set to `off` to disable the 150 ms provisional fallback. Default: enabled |
129
+ | `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 |
130
+ | `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 |
131
+ | `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 |
132
+ | `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 |
133
+ | `PI_GHOST_REGRET_WINDOW_MS` | `3000` | After accept, window in ms during which a >50% deletion of inserted text flags the record `acceptRegret: true` |
134
+ | `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 |
135
+ | `PI_GHOST_METRICS` | unset | Set to `1` to write metrics to `.pi/ghost-autocomplete/metrics.jsonl` |
136
+ | `PI_GHOST_DEBUG_LOG` | unset | Set to `1` to write debug records to `.pi/ghost-autocomplete/debug.jsonl` |
137
+
138
+ ### Note on `temperature=0`
139
+
140
+ `temperature=0` is the default and asks the provider for greedy decoding so
141
+ the same prefix produces the same completion. This is **best-effort, not
142
+ guaranteed**: vendors batch requests across users and kernel scheduling
143
+ differences can produce different greedy paths from one call to the next.
144
+ The session's in-memory completion cache is the real determinism guarantee
145
+ — retyping the same prefix in the same context will reuse the previous
146
+ ghost rather than re-querying. Raise temperature only if you want
147
+ cache-miss diversity (e.g. `PI_GHOST_TEMPERATURE=0.5`).
148
+
149
+ Run `/ghost` inside Pi to see the effective config; `/ghost on` and
150
+ `/ghost off` toggle the feature for the current session.
151
+
152
+ ## Race mode
153
+
154
+ Race mode fires requests to multiple providers simultaneously and uses the
155
+ first valid response. This reduces perceived latency when one provider is slow
156
+ or rate-limited.
157
+
158
+ ```bash
159
+ # Race Groq against Cerebras
160
+ PI_GHOST_RACE_PROVIDERS=groq,cerebras pi ...
161
+
162
+ # Race Groq, Cerebras, and Mercury Edit (Inception Labs)
163
+ PI_GHOST_RACE_PROVIDERS=groq,cerebras,mercury-edit \
164
+ INCEPTION_API_KEY=your-key pi ...
165
+ ```
166
+
167
+ Race-specific environment variables:
168
+
169
+ | Variable | Default | Effect |
170
+ | --- | --- | --- |
171
+ | `PI_GHOST_RACE_PROVIDERS` | unset | Comma-separated list of providers to race (`groq`, `cerebras`, `local`, `mercury-edit`, `mercury-edit-2`, or any `KnownProvider`) |
172
+ | `PI_GHOST_RACE_PAUSE_MS` | 400 | Milliseconds before the second+ provider is unblocked |
173
+ | `PI_GHOST_RACE_COOLDOWN_MS` | 1500 | Quiet period after a winner before the next race starts |
174
+ | `PI_GHOST_RACE_CUTOFF_MS` | 700 | Maximum time to wait for any provider before giving up |
175
+ | `PI_GHOST_RACE_<NAME>_MODEL` | provider default | Override the model for a specific race member (e.g. `PI_GHOST_RACE_GROQ_MODEL`) |
176
+ | `PI_GHOST_RACE_<NAME>_API_KEY` | env var lookup | Override the API key for a specific race member |
177
+ | `PI_GHOST_RACE_<NAME>_BASE_URL` | provider default | Override the base URL for a specific race member |
178
+ | `INCEPTION_API_KEY` | unset | API key for Mercury Edit; used when `mercury-edit` or `mercury-edit-2` appears in the race list |
179
+ | `INCEPTION_BASE_URL` | `https://api.inceptionlabs.ai/v1` | Base URL override for Mercury Edit |
180
+
181
+ `<NAME>` in the per-member variables is the provider name uppercased with
182
+ non-alphanumeric characters replaced by `_` (e.g. `mercury-edit` →
183
+ `MERCURY_EDIT`).
184
+
185
+ ## Metrics and debug logging
186
+
187
+ Enable structured JSONL logs for acceptance tracking and performance analysis:
188
+
189
+ ```bash
190
+ PI_GHOST_METRICS=1 pi ... # writes .pi/ghost-autocomplete/metrics.jsonl
191
+ PI_GHOST_DEBUG_LOG=1 pi ... # writes .pi/ghost-autocomplete/debug.jsonl
192
+ ```
193
+
194
+ Metrics records include: timestamp, request id, provider latencies, completion
195
+ mode (`deterministic` or `llm`), result (`produced`, `empty`, `rejected`,
196
+ `aborted`, `error`), rejection reason, completion length, whether the ghost was
197
+ shown, and acceptance outcome (`accepted`, `dismissed`, `stale`, `expired`).
198
+ Raw prompt text and LLM output are never written to the metrics file. Both
199
+ files rotate at 10 MB.
200
+
201
+ ## User profile
202
+
203
+ The extension keeps a long-lived JSONL store at
204
+ `.pi/ghost-autocomplete/profile.jsonl` to learn slash-command, path, and
205
+ trigram frequencies across sessions. The profile feeds three local-only
206
+ signals:
207
+
208
+ 1. **Slash fast-path.** When the active line starts with `/`, the editor
209
+ serves the most-typed extension as a ghost before the LLM is queried
210
+ (mode `trie`, provider `profile-slash`). Press Right to accept.
211
+ 2. **Profile-bias rerank (M3c hook).** A `profileBias` score plugs into
212
+ the multi-candidate reranker. Wired but only takes effect when the
213
+ provider returns >1 candidate.
214
+ 3. **Bounded path boost.** Persistent path frequency adds a small
215
+ capped boost to deterministic path/fuzzy ranking, capped so it cannot
216
+ beat a fresh exact path-prefix match.
217
+
218
+ What's stored: paths (`src/foo.ts`), slash commands (`/review`),
219
+ trigrams from committed prompts, and acceptance records keyed by the
220
+ SHA-256 hash of the prefix. Raw prefixes and raw completions are
221
+ **never** written. Token tuples are tokenized and capped at 4 for
222
+ prefix tails / 8 for completions.
223
+
224
+ Sanitization runs before persistence: messages with API keys (`sk-…`,
225
+ `ghp_…`, JWT, etc.) or high-entropy tokens (Shannon > 4.0 bits/char,
226
+ length ≥ 20) are dropped entirely. The check is shared with the session
227
+ trie (`src/trie-sanitize.ts`).
228
+
229
+ Rotation: when the JSONL exceeds 2 MB, the store compacts in place —
230
+ sums weights per `(kind, key)`, halves records older than 90 days
231
+ (configurable via `PI_GHOST_PROFILE_DECAY_DAYS`), drops weights below
232
+ 0.1. The original file is preserved as `profile.jsonl.1`.
233
+
234
+ Set `PI_GHOST_PROFILE=off` to disable capture + use entirely.
235
+
236
+ ## Benchmark CLI
237
+
238
+ The `pi-ghost-bench` binary reads a metrics JSONL file and reports p50/p95
239
+ latency, acceptance rates, and whether PRD thresholds pass:
240
+
241
+ ```bash
242
+ pi-ghost-bench # reads .pi/ghost-autocomplete/metrics.jsonl
243
+ pi-ghost-bench path/to/metrics.jsonl # explicit path
244
+ pi-ghost-bench --json # machine-readable output
245
+ ```
246
+
247
+ Exit code is `0` when all thresholds pass, `1` when any fail, `2` on I/O error or when the file contains no records.
248
+
249
+ ## How it works
250
+
251
+ - The extension registers a `GhostEditor` via `ctx.ui.setEditorComponent`.
252
+ `GhostEditor` extends Pi's `CustomEditor` so all built-in app keybindings
253
+ (escape, ctrl+d, model switching, slash-command popup) keep working.
254
+ - After every text change, a debounced request is sent through `pi-ai`'s
255
+ `complete()` with an `AbortSignal` that is canceled on the next keystroke.
256
+ - Before calling the LLM, the editor checks the path index for a
257
+ deterministic match. If the current token is a file path prefix or a fuzzy
258
+ filename with a strong enough score, the deterministic result is shown
259
+ immediately and the LLM call is suppressed.
260
+ - The path index is built asynchronously at session start via `git ls-files`
261
+ from the repository root, capped at 20 000 files. High-confidence
262
+ basename-prefix matches (score ≥85) preempt the LLM unconditionally; lower-
263
+ confidence fuzzy matches only activate when an action word (`open`, `edit`,
264
+ `read`, `show`, `view`, `find`, `check`, `look`, `load`, `import`, `include`,
265
+ `see`, `run`) precedes the token.
266
+ - Recent agent messages come from `ctx.sessionManager.getBranch()` — only
267
+ user/assistant text, no tool calls. Both providers truncate to a small,
268
+ fixed payload so cloud requests don't ship the whole file.
269
+ - Path mentions are extracted from recent conversation messages (slashed
270
+ paths, backticked path-like tokens, and bare filenames with known code
271
+ extensions). These give a ranking boost (+10 exact, +7 basename) to
272
+ deterministic path completions, so recently discussed files surface
273
+ higher.
274
+ - For fuzzy filename matches where the typed token is not a prefix of the
275
+ match, the editor enters **replace mode**: accepting the ghost replaces
276
+ the typed token with the full matched path. A `→ ` prefix is rendered
277
+ before the ghost text to signal this behaviour. Prefix-suffix matches
278
+ still append as usual.
279
+ - `render()` searches for `CURSOR_MARKER` in the lines returned by
280
+ `super.render()` and injects ANSI dim text directly after it. Pi's
281
+ differential renderer redraws only the changed cells — no flicker, no
282
+ full repaint.
283
+ - Right Arrow is intercepted only if a ghost is shown, the popup
284
+ autocomplete is **not** open, and the cursor sits at the end of the text.
285
+ We use `matchesKey(data, "right")` from `@earendil-works/pi-tui`, which
286
+ handles legacy CSI/SS3 sequences and the Kitty keyboard protocol uniformly,
287
+ so kitty / WezTerm / iTerm2 (Kitty-mode) all work. Otherwise the input
288
+ passes through to `super.handleInput`.
289
+ - **Ctrl+Right** accepts the next *word* of the ghost (PRD §M3f). A "word"
290
+ is a run of `\w+` characters, or a single non-word non-whitespace char
291
+ with optional leading whitespace — VS Code's *Cursor Word Right* semantics.
292
+ Repeated presses peel the ghost off one token at a time; the final press
293
+ finalises the metric as `accepted-partial`.
294
+ - **Alt+]** / **Alt+[** cycle through ranked candidates when the provider
295
+ returns more than one (e.g. `openai-compat` with `PI_GHOST_MERCURY_PARALLEL_N>1`
296
+ and `PI_GHOST_TEMPERATURE>0`). Single-candidate sources (cache, trie,
297
+ speculative warm, deterministic path, default LLM fire) treat both keys
298
+ as no-ops. The selected candidate's index is logged as `candidateRank`
299
+ on accept along with `cycledCount`.
300
+ - Visible-column math uses `string-width` + `strip-ansi`. Ghost text is
301
+ clipped grapheme-aware to the remaining columns on the current visual line
302
+ so wide CJK characters and emoji never overflow the row.
303
+
304
+ ## Development
305
+
306
+ ```bash
307
+ npm install
308
+ npm run typecheck # tsc --noEmit against pi-tui / pi-coding-agent v0.74
309
+ npm test # vitest, ~970 unit tests (requires Rust: builds native crate)
310
+ npm run build:dev # plain tsc -> dist/ WITH source maps (local debugging)
311
+ ```
312
+
313
+ The test suite and publish build delegate the crown-jewel logic (session
314
+ trie, reranker, sanitize) to a native Rust binary, so **Rust is a dev-time
315
+ dependency** ([install via rustup](https://rustup.rs)). End users do **not**
316
+ need Rust — they receive a prebuilt, platform-matched `.node` via
317
+ `optionalDependencies`.
318
+
319
+ CI runs all three on Node 20 and 22 (`.github/workflows/ci.yml`).
320
+
321
+ ## Source protection (publish build)
322
+
323
+ This package ships **compiled + obfuscated** runtime, never raw TypeScript
324
+ and never source maps. A private GitHub repo does **not** make an npm
325
+ package private — anything in `files` is downloadable from the public
326
+ registry — so the publish pipeline hardens the runtime against casual
327
+ copying and reverse-engineering.
328
+
329
+ `npm run build` (and the auto-running `prepack` hook on `npm pack` /
330
+ `npm publish`) runs `scripts/build.mjs`, which:
331
+
332
+ 1. builds the **native crown-jewel binary** (Rust, release, symbol-stripped)
333
+ for the current platform and copies it to `dist/` as
334
+ `pi-ghost-native.<triplet>.node` (skip with `PI_GHOST_SKIP_NATIVE=1`) — this
335
+ is for **local dev runs only**; the main package tarball excludes it (see the
336
+ `!dist/**/*.node` negation in `package.json` "files"), since end users get
337
+ the binary from a per-platform optionalDependency, never bundled in `main`;
338
+ 2. emits `.d.ts` declarations only (`tsconfig.build.json`) — no `.js`, no
339
+ `.js.map`, no `.d.ts.map`;
340
+ 3. **prunes internal declarations** — keeps only the `.d.ts` transitively
341
+ reachable from the public entries (`index`, `bench`) and deletes the
342
+ rest. Crown-jewel modules (`fast-path-engine`, `session-trie`, `reranker`,
343
+ `provisional`, `regret-tracker`, `profile`, `edit-history-ring`, …) are
344
+ internal and never re-exported, so their type declarations are removed
345
+ from the tarball — they would otherwise be a symbol-level roadmap into
346
+ the obfuscated bundle. A dangling-reference guard fails the build if
347
+ pruning would break consumer type resolution;
348
+ 4. bundles every entry point with **esbuild** into a single minified ESM
349
+ chunk per entry (collapsing ~40 source modules into opaque bundles),
350
+ keeping `@earendil-works/*` peers + `string-width`/`strip-ansi` external;
351
+ 5. obfuscates each bundle with **javascript-obfuscator**;
352
+ 6. re-adds exactly one `#!/usr/bin/env node` shebang to the two `bin` CLIs;
353
+ 7. fails loudly if any `*.map` reaches `dist/`.
354
+
355
+ Obfuscation is tuned to be **latency-safe**: this is a ghost-text engine
356
+ where milliseconds matter, so control-flow flattening (the one transform
357
+ with real runtime cost) is OFF. What's ON: hexadecimal identifier renaming,
358
+ string-array encoding (base64) with two wrapper layers and hexadecimal
359
+ indexes, string splitting (5-char chunks), numeric-literal-to-expression
360
+ conversion (V8 constant-folds these at parse time, so zero runtime cost),
361
+ object-key transformation, and light dead-code injection. Public export
362
+ names are preserved so the pi extension loader and TS consumers keep working.
363
+
364
+ Tune the trade-off in `OBFUSCATOR_OPTIONS` inside `scripts/build.mjs`:
365
+
366
+ - **Smaller tarball / faster parse** → lower `deadCodeInjectionThreshold`
367
+ (e.g. `0`) and `stringArrayThreshold` (e.g. `0.4`). `index.js` is ~575 kB
368
+ with the hardened settings; dropping dead-code injection brings it back
369
+ near the ~110 kB pre-obfuscation size.
370
+ - **Stronger protection** (test thoroughly first) → enable
371
+ `controlFlowFlattening`, but expect a measurable latency hit on the
372
+ autocomplete fast path.
373
+
374
+ What still ships readable: the `.d.ts` declarations for the **public API
375
+ only** (internal module declarations are pruned in step 2), plus the
376
+ JS wiring that calls into the native binary.
377
+
378
+ ### Native crown jewels (tier-C hardening)
379
+
380
+ The tuned algorithm values — the reranker weights (`0.40/0.25/0.15/0.10/0.10`),
381
+ the prefix-aware `lengthPrior` curve, the confidence gates (`0.55/0.05`),
382
+ the session-trie lookup gates + typeahead + decay, and the secret/volatile
383
+ regex patterns — live **only** in a prebuilt, symbol-stripped native
384
+ `.node` binary (`native/`, Rust + napi-rs). No tuned literal appears in the
385
+ shipped JS: the wrapper fetches constants from the binary at load time, and
386
+ the JS `trie-sanitize.ts` / `session-trie.ts` / `reranker.ts` are thin
387
+ delegates. Feature extraction (merging, profile/edit-proximity closures)
388
+ stays in JS because it needs JS closures; the scoring math crosses the
389
+ boundary.
390
+
391
+ A reverser with the obfuscated bundle alone sees only *calls* into a native
392
+ binary, not the weights or the curves **as static literals**. Reading them out
393
+ of the binary requires disassembling the stripped Rust (Ghidra/IDA over
394
+ hours-to-days), not pasting into `obfuscator-io-deobfuscator` or asking an LLM.
395
+ This raises the cost of *static* recovery versus obfuscation alone — but see
396
+ the honest residual below: the scoring is recoverable *dynamically* regardless
397
+ of where the constants live.
398
+
399
+ **Distribution.** The binary ships per-platform as `optionalDependencies`
400
+ (`pi-ghost-autocomplete-<os>-<cpu>[-libc>`), following the @napi-rs
401
+ convention. End users on supported platforms need **no Rust toolchain** —
402
+ npm installs only the matching prebuild. Unsupported platforms fail at load
403
+ (by design: there is no recoverable JS fallback for the crown jewels). The
404
+ trade-off is a per-platform CI/release matrix (`.github/workflows/release.yml`).
405
+
406
+ **Honest residual.** This is not cryptographic protection, and two of the
407
+ "hidden" pieces are weaker than the binary boundary implies:
408
+
409
+ - **The reranker scoring is linear and black-box recoverable.** The score is a
410
+ weighted sum of per-axis normalized features, and the public API returns the
411
+ per-axis `scoreBreakdown` alongside the final `score`. Feeding ~5+ candidate
412
+ sets and solving the resulting linear system recovers the weight vector and
413
+ gate thresholds **without ever touching the binary** — no disassembly needed.
414
+ Hiding the literals raises the bar against copy-paste, not against a
415
+ competitor who probes the I/O.
416
+ - **The secret/volatile regexes are public patterns.** `sk-ant`, `ghp_`,
417
+ `AKIA`, `AIza`, JWT shape, etc. are the same prefixes published by gitleaks /
418
+ trufflehog. Moving them into the binary hides nothing of value; it's done for
419
+ consistency, not secrecy.
420
+
421
+ What genuinely benefits from the boundary is the **session-trie lookup
422
+ algorithm shape** (gates, typeahead-delta, decay, volatile verdict) — that
423
+ structure is not trivially recoverable from I/O. Runtime instrumentation
424
+ (`--inspect`, `LD_PRELOAD`, `process.dlopen` hooks) can still observe
425
+ inputs/outputs of everything. What tier-C removes is the *automated,
426
+ minutes-to-recover static* attack surface that pure JS obfuscation leaves. The
427
+ real enforcement remains the **SSPL-1.0** license; the native boundary removes
428
+ the temptation for the honest-but-curious and raises the cost for a casual
429
+ competitor from "paste into a deobfuscator" to "learn Rust disassembly (or
430
+ write a solver)."
431
+
432
+ > **Platform support.** Prebuilt binaries ship for win32-x64-msvc,
433
+ > darwin-arm64, linux-x64-gnu, linux-arm64-gnu. **Intel Mac (darwin-x64)
434
+ > is not supported** — Apple stopped selling Intel Macs in 2020; Intel Mac
435
+ > users can run via Rosetta 2. **musl (Alpine) is not supported** — there is
436
+ > no JS fallback, so unsupported platforms fail at load by design.
437
+
438
+ Verify what a release contains at any time:
439
+
440
+ ```bash
441
+ npm pack --dry-run
442
+ # expect: obfuscated dist/*.js + public dist/*.d.ts + README only.
443
+ # must show NO *.map, NO raw *.ts source (note: *.d.ts declarations are fine
444
+ # and expected), and NO *.node (the binary ships via optionalDependencies).
445
+ ```
446
+
447
+ ## Limitations
448
+
449
+ - No native Ollama provider in `pi-ai`; we configure an `openai-compat`
450
+ model pointed at Ollama's `/v1` endpoint. Any OpenAI-compatible local
451
+ server works the same way.
452
+ - Pin to `^0.74` of `pi-tui` / `pi-coding-agent` / `pi-ai` /
453
+ `pi-agent-core` (all under `@earendil-works`). The relevant interfaces are
454
+ pre-1.0; expect occasional follow-up bumps.
455
+ - Predictions are shown only at the end of the buffer to avoid splitting
456
+ lines mid-stream.
457
+ - The path index covers only tracked Git files. Untracked files are not
458
+ indexed in v1.
459
+ - Three-provider racing may create elevated cost or rate-limit pressure at
460
+ high typing speeds.
461
+
462
+ ## License
463
+
464
+ SSPL-1.0 (Server Side Public License v1) — see [LICENSE](./LICENSE).