pi-ghost-autocomplete 0.3.2 → 0.5.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 (170) hide show
  1. package/LICENSE +561 -0
  2. package/README.md +186 -4
  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 +46 -3
  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 +7 -2
  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-chat-provider.d.ts +31 -0
  24. package/dist/providers/openai-compat-provider.d.ts +24 -1
  25. package/dist/providers/race.d.ts +4 -1
  26. package/dist/providers/router.d.ts +59 -0
  27. package/dist/providers/types.d.ts +13 -1
  28. package/dist/recent-paths.d.ts +4 -1
  29. package/dist/replay-cli.js +1 -166
  30. package/dist/replay.d.ts +2 -7
  31. package/package.json +10 -6
  32. package/dist/bench-cli.d.ts +0 -3
  33. package/dist/bench-cli.d.ts.map +0 -1
  34. package/dist/bench-cli.js.map +0 -1
  35. package/dist/bench.d.ts.map +0 -1
  36. package/dist/bench.js.map +0 -1
  37. package/dist/cache.d.ts +0 -124
  38. package/dist/cache.d.ts.map +0 -1
  39. package/dist/cache.js +0 -173
  40. package/dist/cache.js.map +0 -1
  41. package/dist/candidate-rerank.d.ts +0 -57
  42. package/dist/candidate-rerank.d.ts.map +0 -1
  43. package/dist/candidate-rerank.js +0 -78
  44. package/dist/candidate-rerank.js.map +0 -1
  45. package/dist/config.d.ts.map +0 -1
  46. package/dist/config.js +0 -317
  47. package/dist/config.js.map +0 -1
  48. package/dist/context-aggregator.d.ts.map +0 -1
  49. package/dist/context-aggregator.js +0 -87
  50. package/dist/context-aggregator.js.map +0 -1
  51. package/dist/context-selector.d.ts +0 -128
  52. package/dist/context-selector.d.ts.map +0 -1
  53. package/dist/context-selector.js +0 -300
  54. package/dist/context-selector.js.map +0 -1
  55. package/dist/cycle-manager.d.ts.map +0 -1
  56. package/dist/cycle-manager.js +0 -168
  57. package/dist/cycle-manager.js.map +0 -1
  58. package/dist/edit-history-ring.d.ts +0 -148
  59. package/dist/edit-history-ring.d.ts.map +0 -1
  60. package/dist/edit-history-ring.js +0 -262
  61. package/dist/edit-history-ring.js.map +0 -1
  62. package/dist/entropy-stop.d.ts.map +0 -1
  63. package/dist/entropy-stop.js +0 -141
  64. package/dist/entropy-stop.js.map +0 -1
  65. package/dist/eval-dataset.d.ts.map +0 -1
  66. package/dist/eval-dataset.js +0 -127
  67. package/dist/eval-dataset.js.map +0 -1
  68. package/dist/fast-path-engine.d.ts +0 -286
  69. package/dist/fast-path-engine.d.ts.map +0 -1
  70. package/dist/fast-path-engine.js +0 -341
  71. package/dist/fast-path-engine.js.map +0 -1
  72. package/dist/fuzzy-matcher.d.ts.map +0 -1
  73. package/dist/fuzzy-matcher.js +0 -162
  74. package/dist/fuzzy-matcher.js.map +0 -1
  75. package/dist/ghost-editor.d.ts.map +0 -1
  76. package/dist/ghost-editor.js +0 -1491
  77. package/dist/ghost-editor.js.map +0 -1
  78. package/dist/index.d.ts.map +0 -1
  79. package/dist/index.js.map +0 -1
  80. package/dist/keystroke-timer.d.ts +0 -59
  81. package/dist/keystroke-timer.d.ts.map +0 -1
  82. package/dist/keystroke-timer.js +0 -94
  83. package/dist/keystroke-timer.js.map +0 -1
  84. package/dist/metrics-builder.d.ts +0 -71
  85. package/dist/metrics-builder.d.ts.map +0 -1
  86. package/dist/metrics-builder.js +0 -71
  87. package/dist/metrics-builder.js.map +0 -1
  88. package/dist/metrics.d.ts.map +0 -1
  89. package/dist/metrics.js +0 -212
  90. package/dist/metrics.js.map +0 -1
  91. package/dist/path-index.d.ts.map +0 -1
  92. package/dist/path-index.js +0 -148
  93. package/dist/path-index.js.map +0 -1
  94. package/dist/path-matcher.d.ts.map +0 -1
  95. package/dist/path-matcher.js +0 -88
  96. package/dist/path-matcher.js.map +0 -1
  97. package/dist/profile-store.d.ts.map +0 -1
  98. package/dist/profile-store.js +0 -199
  99. package/dist/profile-store.js.map +0 -1
  100. package/dist/profile.d.ts.map +0 -1
  101. package/dist/profile.js +0 -434
  102. package/dist/profile.js.map +0 -1
  103. package/dist/providers/cloud-provider.d.ts.map +0 -1
  104. package/dist/providers/cloud-provider.js +0 -109
  105. package/dist/providers/cloud-provider.js.map +0 -1
  106. package/dist/providers/local-provider.d.ts.map +0 -1
  107. package/dist/providers/local-provider.js +0 -48
  108. package/dist/providers/local-provider.js.map +0 -1
  109. package/dist/providers/openai-compat-provider.d.ts.map +0 -1
  110. package/dist/providers/openai-compat-provider.js +0 -215
  111. package/dist/providers/openai-compat-provider.js.map +0 -1
  112. package/dist/providers/openai-completions-runner.d.ts +0 -52
  113. package/dist/providers/openai-completions-runner.d.ts.map +0 -1
  114. package/dist/providers/openai-completions-runner.js +0 -141
  115. package/dist/providers/openai-completions-runner.js.map +0 -1
  116. package/dist/providers/race.d.ts.map +0 -1
  117. package/dist/providers/race.js +0 -441
  118. package/dist/providers/race.js.map +0 -1
  119. package/dist/providers/truncate.d.ts +0 -7
  120. package/dist/providers/truncate.d.ts.map +0 -1
  121. package/dist/providers/truncate.js +0 -57
  122. package/dist/providers/truncate.js.map +0 -1
  123. package/dist/providers/types.d.ts.map +0 -1
  124. package/dist/providers/types.js +0 -60
  125. package/dist/providers/types.js.map +0 -1
  126. package/dist/provisional-manager.d.ts +0 -93
  127. package/dist/provisional-manager.d.ts.map +0 -1
  128. package/dist/provisional-manager.js +0 -98
  129. package/dist/provisional-manager.js.map +0 -1
  130. package/dist/provisional.d.ts +0 -150
  131. package/dist/provisional.d.ts.map +0 -1
  132. package/dist/provisional.js +0 -238
  133. package/dist/provisional.js.map +0 -1
  134. package/dist/recent-paths.d.ts.map +0 -1
  135. package/dist/recent-paths.js +0 -114
  136. package/dist/recent-paths.js.map +0 -1
  137. package/dist/regret-tracker.d.ts +0 -84
  138. package/dist/regret-tracker.d.ts.map +0 -1
  139. package/dist/regret-tracker.js +0 -145
  140. package/dist/regret-tracker.js.map +0 -1
  141. package/dist/replay-cli.d.ts +0 -13
  142. package/dist/replay-cli.d.ts.map +0 -1
  143. package/dist/replay-cli.js.map +0 -1
  144. package/dist/replay.d.ts.map +0 -1
  145. package/dist/replay.js +0 -225
  146. package/dist/replay.js.map +0 -1
  147. package/dist/reranker.d.ts +0 -181
  148. package/dist/reranker.d.ts.map +0 -1
  149. package/dist/reranker.js +0 -324
  150. package/dist/reranker.js.map +0 -1
  151. package/dist/session-trie.d.ts +0 -103
  152. package/dist/session-trie.d.ts.map +0 -1
  153. package/dist/session-trie.js +0 -280
  154. package/dist/session-trie.js.map +0 -1
  155. package/dist/speculative.d.ts +0 -74
  156. package/dist/speculative.d.ts.map +0 -1
  157. package/dist/speculative.js +0 -107
  158. package/dist/speculative.js.map +0 -1
  159. package/dist/tool-summarize.d.ts +0 -40
  160. package/dist/tool-summarize.d.ts.map +0 -1
  161. package/dist/tool-summarize.js +0 -245
  162. package/dist/tool-summarize.js.map +0 -1
  163. package/dist/trie-sanitize.d.ts +0 -22
  164. package/dist/trie-sanitize.d.ts.map +0 -1
  165. package/dist/trie-sanitize.js +0 -142
  166. package/dist/trie-sanitize.js.map +0 -1
  167. package/dist/versions.d.ts +0 -23
  168. package/dist/versions.d.ts.map +0 -1
  169. package/dist/versions.js +0 -31
  170. package/dist/versions.js.map +0 -1
@@ -1,1491 +0,0 @@
1
- import { CustomEditor } from "@earendil-works/pi-coding-agent";
2
- import { CURSOR_MARKER, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
- import stringWidth from "string-width";
4
- import stripAnsi from "strip-ansi";
5
- import { fingerprintProvider, fingerprintRecentMessages, makeKey, } from "./cache.js";
6
- import { FastPathEngine, decideDeterministic } from "./fast-path-engine.js";
7
- import { getRecentMessages } from "./context-aggregator.js";
8
- import { appendExamples, buildExamplesFromCommit, parseEvalCaptureEnv, } from "./eval-dataset.js";
9
- import { readContextEnv, selectContext } from "./context-selector.js";
10
- import { parseToolMaxEnv, summarizeAndCapTools } from "./tool-summarize.js";
11
- import { containsSensitive, redactSensitive } from "./trie-sanitize.js";
12
- import { diffStrings as diffEditStrings, editProximityScore as computeEditProximityScore, tokenize as tokenizeEdit, } from "./edit-history-ring.js";
13
- import { buildShownMetricsRecord } from "./metrics-builder.js";
14
- import { arm as armRegret, attachToRecord as attachRegret, expire as expireRegret, observe as observeRegret, parseRegretWindowEnv, } from "./regret-tracker.js";
15
- import { CycleManager } from "./cycle-manager.js";
16
- // PRD §M3e/f — pure helpers re-exported for back-compat with existing tests
17
- // that import via the editor module. cycle-manager.ts is the source of truth.
18
- export { cycleCandidateIdx, nextWordChunk } from "./cycle-manager.js";
19
- import { rerankCandidateSet } from "./candidate-rerank.js";
20
- import { RaceProvider } from "./providers/race.js";
21
- import { wrapAsMulti, wrapAsStream, } from "./providers/types.js";
22
- import { extractRecentPaths, getRoleWeightedRecent } from "./recent-paths.js";
23
- import { seedTrieFromCommit } from "./session-trie.js";
24
- import { KeystrokeTimer } from "./keystroke-timer.js";
25
- import { combinedVersionFingerprint } from "./versions.js";
26
- const RECENT_USER_MAX = 2;
27
- const RECENT_ASSISTANT_MAX = 1;
28
- const RECENT_PATHS_TTL_MS = 5_000;
29
- /**
30
- * Hover-time threshold for the short-hover-dismiss negative signal (#3).
31
- * A ghost dismissed inside this window indicates the user saw it and
32
- * actively didn't want it — production systems (Copilot, JetBrains AI)
33
- * treat this as soft-negative for ranking/weighting. Tunable via
34
- * `PI_GHOST_SHORT_HOVER_MS`.
35
- */
36
- const DEFAULT_SHORT_HOVER_MS = 800;
37
- /** Trie penalty magnitude on short-hover dismiss. Half the default 0.5
38
- * used for typed-past divergent commits — softer signal. */
39
- const SHORT_HOVER_TRIE_PENALTY = 0.25;
40
- /**
41
- * Number of consecutive divergent stales on the same line that trips the
42
- * typed-past negative signal (#5). At threshold we apply a full-magnitude
43
- * trie penalty against the original (prefix, ghost) pair and stamp the
44
- * metric record so M8/future weighting can read the signal directly.
45
- */
46
- const TYPED_PAST_DIVERGENT_THRESHOLD = 3;
47
- /** Trie penalty magnitude on typed-past divergent run — matches the
48
- * "divergent dismiss" baseline documented in `session-trie.ts`. */
49
- const TYPED_PAST_TRIE_PENALTY = 0.5;
50
- /**
51
- * #5 helper — classify a stale event as divergent or typed-through given
52
- * the ghost state at render time and the post-change text. Returns true
53
- * when the user typed characters that are NOT a prefix of the suggested
54
- * ghost. Pure / exported for testability — the editor wraps this in
55
- * `recordDivergentTyped` to track the consecutive run.
56
- */
57
- export function isTypedPastDivergent(ghost, ghostForText, textAfterChange) {
58
- if (!ghost)
59
- return false;
60
- if (!textAfterChange.startsWith(ghostForText))
61
- return true;
62
- const typedTail = textAfterChange.slice(ghostForText.length);
63
- if (typedTail.length === 0)
64
- return false;
65
- return !ghost.startsWith(typedTail);
66
- }
67
- /** Parse `PI_GHOST_SHORT_HOVER_MS`. Falls back to the default on invalid
68
- * or non-positive input. Exposed for tests; called once per dismiss. */
69
- export function parseShortHoverEnv(raw) {
70
- if (raw === undefined)
71
- return DEFAULT_SHORT_HOVER_MS;
72
- const n = Number(raw);
73
- if (!Number.isFinite(n) || n <= 0)
74
- return DEFAULT_SHORT_HOVER_MS;
75
- return n;
76
- }
77
- const DIM_OPEN = "\x1b[2;90m";
78
- const DIM_CLOSE = "\x1b[0m";
79
- const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
80
- export class GhostEditor extends CustomEditor {
81
- provider;
82
- config;
83
- extCtx;
84
- metrics;
85
- onError;
86
- profile;
87
- ghostText = null;
88
- ghostForText = "";
89
- keystrokeTimer = new KeystrokeTimer();
90
- /**
91
- * Tracks consecutive divergent stales on the same line for the #5
92
- * typed-past negative signal. Reset by any accept or a non-divergent
93
- * (i.e., user-typed-through) stale. Carries the original `(prefix,
94
- * ghost)` so a single penalty at threshold can be applied against the
95
- * pair that was actually being typed-past.
96
- */
97
- divergentRun = null;
98
- ghostReplaceFrom = null;
99
- debounceTimer = null;
100
- inflight = null;
101
- lastTextSeen = "";
102
- disposed = false;
103
- shownMetric = null;
104
- recentPaths = new Set();
105
- recentPathsAt = 0;
106
- fastPath = new FastPathEngine();
107
- providerFingerprint;
108
- versionFingerprint = combinedVersionFingerprint();
109
- /** Tracks the prefix shown alongside the current ghost so commit-time
110
- * reconciliation can decide accepted vs divergent. */
111
- lastAcceptedGhost = null;
112
- /** PRD §M3e/f — CycleManager owns the ranked candidate list, cycling (Alt+]/Alt+[),
113
- * word-level partial accepts (Ctrl+Right), and related accounting. */
114
- cycleMgr = new CycleManager();
115
- // PRD §M3.5b — provisional ghost manager moved into FastPathEngine
116
- // (refactor #57 phase 3). Editor accesses via fastPath.{armProvisional,
117
- // reconcileProvisional, isProvisionalShown, cancelProvisionalTimer,
118
- // clearProvisional}.
119
- /** PRD §M5a — regret detection after accept. Pending while the
120
- * window is open; flushed on regret or deadline. */
121
- pendingRegret = null;
122
- regretTimer = null;
123
- constructor(tui, theme, keybindings, extCtx, config, provider, metrics = null, pathIndex = null, onError = null, profile = null) {
124
- super(tui, theme, keybindings);
125
- this.provider = provider;
126
- this.config = config;
127
- this.extCtx = extCtx;
128
- this.metrics = metrics;
129
- this.fastPath.setPathIndex(pathIndex);
130
- this.onError = onError;
131
- this.profile = profile;
132
- this.lastTextSeen = this.getText();
133
- this.providerFingerprint = providerFingerprintFromConfig(config.provider);
134
- }
135
- setPathIndex(index) {
136
- this.fastPath.setPathIndex(index);
137
- }
138
- setProfile(profile) {
139
- this.profile = profile;
140
- }
141
- /**
142
- * PRD §M3g — diagnostic snapshot for the `/ghost` command. Pure read,
143
- * no side effects. Numbers are point-in-time; the editor doesn't keep
144
- * a running window. `cooldownMs` is reported when the active provider
145
- * is a `RaceProvider`; otherwise omitted.
146
- */
147
- getDiagnostics() {
148
- const cache = this.fastPath.cache.stats;
149
- const trie = this.fastPath.trie.stats;
150
- const ps = this.profile;
151
- let profile;
152
- if (ps) {
153
- const idx = ps.getIndex();
154
- profile = {
155
- enabled: !ps.disabled,
156
- ngrams: idx.ngram.size,
157
- paths: idx.path.size,
158
- slash: idx.slash.size,
159
- accepts: idx.totalAcceptRecords,
160
- decayDays: ps.decayDaysSetting,
161
- };
162
- }
163
- else {
164
- profile = { enabled: false, ngrams: 0, paths: 0, slash: 0, accepts: 0, decayDays: 0 };
165
- }
166
- const race = this.provider instanceof RaceProvider
167
- ? {
168
- cooldown: this.provider.getCooldownState(),
169
- members: this.provider.getMemberStats(),
170
- }
171
- : null;
172
- const now = Date.now();
173
- const pendingRegret = this.pendingRegret
174
- ? {
175
- remainingMs: Math.max(0, this.pendingRegret.deadlineMs - now),
176
- insertedLen: this.pendingRegret.insertedText.length,
177
- }
178
- : null;
179
- const capabilities = {
180
- streaming: typeof this.provider?.predictStream === "function",
181
- multiCandidate: typeof this.provider?.predictMulti === "function",
182
- preflight: typeof this.provider?.preflight === "function",
183
- logprobs: this.provider?.supportsLogprobs === true,
184
- };
185
- const editHistory = this.buildEditHistoryDiag();
186
- return {
187
- provider: this.provider?.name ?? null,
188
- providerFingerprint: this.providerFingerprint,
189
- versionFingerprint: this.versionFingerprint,
190
- capabilities,
191
- cache,
192
- trie,
193
- profile,
194
- race,
195
- ghostShown: this.ghostText !== null,
196
- pendingRegret,
197
- editHistory,
198
- };
199
- }
200
- /**
201
- * Build the edit-history section of `/ghost` diag. Snapshot is taken at
202
- * call time; `topAdded` / `topRemoved` expose token *lists only* (no
203
- * raw text — privacy contract from spec §6.5).
204
- */
205
- buildEditHistoryDiag() {
206
- const ring = this.fastPath.editHistory;
207
- const enabled = this.fastPath.editHistoryEnabled;
208
- const events = ring.size;
209
- if (events === 0) {
210
- return {
211
- enabled,
212
- events: 0,
213
- capacity: ring.capacity,
214
- uris: 0,
215
- topAdded: [],
216
- topRemoved: [],
217
- oldestAgeMs: null,
218
- halfLifeMs: ring.halfLifeMs,
219
- };
220
- }
221
- const now = Date.now();
222
- const snapshot = ring.snapshot(now);
223
- const index = ring.index(now);
224
- const topK = (m, k) => [...m.entries()]
225
- .sort((a, b) => b[1] - a[1])
226
- .slice(0, k)
227
- .map(([t]) => t);
228
- const oldestTs = snapshot.length > 0 ? snapshot[snapshot.length - 1].ts : null;
229
- return {
230
- enabled,
231
- events,
232
- capacity: ring.capacity,
233
- uris: index.recentUris.size,
234
- topAdded: topK(index.addedFreq, 4),
235
- topRemoved: topK(index.removedFreq, 4),
236
- oldestAgeMs: oldestTs === null ? null : Math.max(0, now - oldestTs),
237
- halfLifeMs: ring.halfLifeMs,
238
- };
239
- }
240
- setProvider(provider) {
241
- this.provider = provider;
242
- this.clearGhost();
243
- }
244
- dispose() {
245
- this.disposed = true;
246
- this.finalizeShown("expired");
247
- this.clearGhost();
248
- // Flush any pending regret record BEFORE the metrics logger
249
- // drains its queue.
250
- this.flushRegret();
251
- void this.metrics?.flush();
252
- void this.profile?.flush();
253
- }
254
- handleInput(data) {
255
- // Dispatch to focused sub-handlers. Each returns true when it fully
256
- // owns the keystroke (no super.handleInput, no text-change observation
257
- // needed). Order matters: full accept beats partial-word beats cycle.
258
- if (this.handleFullAccept(data))
259
- return;
260
- if (this.handlePartialWordAccept(data))
261
- return;
262
- if (this.handleCycle(data))
263
- return;
264
- this.maybeDismissGhost(data);
265
- // Snapshot Enter-committed text BEFORE the base editor clears the
266
- // buffer; the post-commit hook fires after super.handleInput drains.
267
- const isEnter = matchesKey(data, "enter");
268
- const textPreCommit = isEnter ? this.getText().trim() : "";
269
- super.handleInput(data);
270
- if (isEnter && textPreCommit) {
271
- this.onPromptCommitted(textPreCommit);
272
- }
273
- this.observePostInputTextChange();
274
- }
275
- /**
276
- * Right Arrow → full ghost accept (append-mode appends, replace-mode
277
- * replaces the typed token). Seeds the trie + profile with the accepted
278
- * (prefix, ghost) pair for append-mode only — replace-mode ghosts are
279
- * structurally incompatible with the trie's append model.
280
- */
281
- handleFullAccept(data) {
282
- if (!matchesKey(data, "right") ||
283
- !this.ghostText ||
284
- this.isShowingAutocomplete() ||
285
- !this.cursorAtEnd()) {
286
- return false;
287
- }
288
- const accepted = this.ghostText;
289
- const replaceFrom = this.ghostReplaceFrom;
290
- // Capture pre-insert snapshot for regret tracking BEFORE we
291
- // mutate the buffer. For replace-mode, `textBefore` is the
292
- // line slice up to the replacement origin; for append-mode,
293
- // it's the entire pre-cursor text.
294
- const preInsertText = this.getText();
295
- let textBeforeForRegret;
296
- if (replaceFrom) {
297
- const lines = preInsertText.split("\n");
298
- const headLines = lines.slice(0, replaceFrom.line);
299
- const partial = (lines[replaceFrom.line] ?? "").slice(0, replaceFrom.col);
300
- textBeforeForRegret = [...headLines, partial].join("\n");
301
- }
302
- else {
303
- textBeforeForRegret = preInsertText;
304
- }
305
- // Seed trie BEFORE finalizeShown clears `shownMetric`. Weight +3 for
306
- // full accept (PRD §M2). Replace-mode skipped — its "ghost" REPLACES
307
- // a typed token instead of appending, so storing (prefix, ghost)
308
- // would yield broken trie suggestions that append the ghost verbatim.
309
- if (!replaceFrom) {
310
- const prefixForTrie = this.lastSingleLinePrefix();
311
- if (prefixForTrie) {
312
- this.fastPath.recordAccept(prefixForTrie, accepted, 3);
313
- this.lastAcceptedGhost = { prefix: prefixForTrie, ghost: accepted };
314
- // PRD §M4 — profile accept capture mirrors the trie seed.
315
- this.profile?.captureAccept(prefixForTrie, accepted, 3);
316
- }
317
- }
318
- this.finalizeShown("accepted", {
319
- textBefore: textBeforeForRegret,
320
- insertedText: accepted,
321
- textAfter: "",
322
- });
323
- this.clearGhost();
324
- if (replaceFrom) {
325
- this.replaceTokenAtCursor(replaceFrom, accepted);
326
- }
327
- else {
328
- this.insertTextAtCursor(accepted);
329
- }
330
- this.lastTextSeen = this.getText();
331
- return true;
332
- }
333
- /**
334
- * PRD §M3f — Ctrl+Right consumes the next word of the ghost.
335
- * Append-mode only; replace-mode ghosts (fuzzy/path) accept atomically
336
- * via Right. Returns true iff the keystroke was claimed (chunk inserted
337
- * or final-chunk path taken). A no-op move (empty chunk) falls through.
338
- */
339
- handlePartialWordAccept(data) {
340
- if (!matchesKey(data, "ctrl+right") ||
341
- !this.ghostText ||
342
- this.ghostReplaceFrom ||
343
- this.isShowingAutocomplete() ||
344
- !this.cursorAtEnd()) {
345
- return false;
346
- }
347
- const move = this.cycleMgr.acceptPartialWordAppend();
348
- const chunk = move.chunk;
349
- if (chunk.length === 0)
350
- return false;
351
- const remaining = move.remaining;
352
- if (remaining === null) {
353
- // Last chunk consumes the ghost — record as accepted-partial per
354
- // PRD §M5a so analysis can distinguish word-by-word from a single
355
- // full Right-Arrow accept. Trie seeding deliberately skipped:
356
- // by the final chunk, `ghostText` may be just the last word
357
- // (earlier chunks already inserted, `ghostText` shrunk each step)
358
- // and `lastSingleLinePrefix()` reflects the post-insert prefix —
359
- // neither matches the (prefix, ghost) pair the trie wants.
360
- const preInsertText = this.getText();
361
- this.finalizeShown("accepted-partial", {
362
- textBefore: preInsertText,
363
- insertedText: chunk,
364
- textAfter: "",
365
- });
366
- this.clearGhost();
367
- this.insertTextAtCursor(chunk);
368
- this.lastTextSeen = this.getText();
369
- return true;
370
- }
371
- // More text remains in the ghost — keep showing the tail.
372
- this.insertTextAtCursor(chunk);
373
- this.ghostText = remaining;
374
- this.ghostForText = this.getText();
375
- this.lastTextSeen = this.ghostForText;
376
- this.invalidate();
377
- return true;
378
- }
379
- /**
380
- * PRD §M3e — Alt+]/Alt+[ cycle through ranked candidates. Append-mode
381
- * only and only when more than one candidate exists. Clamp at edges
382
- * (no wrap). An edge press is a silent no-op: ghost is preserved
383
- * (see `maybeDismissGhost`) and cycledCount is NOT incremented since
384
- * the user didn't actually cycle.
385
- */
386
- handleCycle(data) {
387
- const isAltBracket = matchesKey(data, "alt+]") || matchesKey(data, "alt+[");
388
- if (!isAltBracket ||
389
- !this.ghostText ||
390
- this.cycleMgr.candidates.length <= 1 ||
391
- this.ghostReplaceFrom ||
392
- this.isShowingAutocomplete() ||
393
- !this.cursorAtEnd()) {
394
- return false;
395
- }
396
- const dir = matchesKey(data, "alt+]") ? 1 : -1;
397
- if (this.cycleMgr.cycle(dir)) {
398
- this.ghostText = this.cycleMgr.current ?? "";
399
- // Keep shownMetric.completion in sync with what the user sees so
400
- // `completionLength` on the final accept record reflects the
401
- // cycled-to candidate, not the originally-displayed one.
402
- if (this.shownMetric)
403
- this.shownMetric.completion = this.ghostText;
404
- this.invalidate();
405
- }
406
- return true;
407
- }
408
- /**
409
- * Keys we explicitly own preserve the ghost; everything else dismisses.
410
- * Alt+]/Alt+[ always preserve (match VS Code: alt+] on a single
411
- * candidate is a silent no-op, ghost stays). Ctrl+Right preserves
412
- * ONLY in append-mode — for replace-mode (fuzzy/path) ghosts,
413
- * Ctrl+Right is normal cursor word-right nav, so the ghost should
414
- * dismiss before the parent editor moves the cursor.
415
- */
416
- maybeDismissGhost(data) {
417
- if (!this.ghostText)
418
- return;
419
- const preservesGhost = matchesKey(data, "right") ||
420
- matchesKey(data, "alt+]") ||
421
- matchesKey(data, "alt+[") ||
422
- (matchesKey(data, "ctrl+right") && !this.ghostReplaceFrom);
423
- if (!preservesGhost) {
424
- this.finalizeShown("dismissed");
425
- this.clearGhost();
426
- }
427
- }
428
- /**
429
- * Observe post-`super.handleInput` text change: feed the keystroke timer
430
- * (single-char insertions only), check pending regret, and dispatch
431
- * to `handleTextChange` for the next prediction cycle.
432
- */
433
- observePostInputTextChange() {
434
- const text = this.getText();
435
- if (text === this.lastTextSeen)
436
- return;
437
- const prevText = this.lastTextSeen;
438
- // PRD §M5 — feed single-character insertions into the keystroke timer
439
- // so `estimatedTimeSavedMs` reflects this user's actual typing speed
440
- // once warmup completes. Multi-char deltas (paste) and deletes are
441
- // ignored — they're not "keystrokes" in the inter-key-time sense
442
- // PRD §M5 calibrates against.
443
- if (text.length - prevText.length === 1) {
444
- this.keystrokeTimer.record(Date.now());
445
- }
446
- this.lastTextSeen = text;
447
- this.recordEditEvent(prevText, text);
448
- // PRD §M5a — per-keystroke regret check. Runs BEFORE the next
449
- // suggestion is requested so a regret flush doesn't race the
450
- // re-arm path. The accept that armed the watch installed
451
- // `pendingRegret`; we observe against current text.
452
- if (this.pendingRegret) {
453
- const result = observeRegret(this.pendingRegret, text, Date.now());
454
- if (result.flush)
455
- this.resolvePendingRegret(result);
456
- }
457
- this.handleTextChange(text);
458
- }
459
- /**
460
- * Enhancement #1 — capture an edit event into the FastPathEngine's
461
- * `EditHistoryRing`. Diffs `prev` vs `next` to derive the minimal
462
- * `{added, removed}` slice, sanitizes both sides (event dropped on a
463
- * sensitive hit), tokenizes, and records.
464
- *
465
- * No-op when `PI_GHOST_EDIT_HISTORY=0` (master switch read at engine
466
- * construction). Single-buffer URI sentinel `"prompt"` — the editor
467
- * owns one document.
468
- */
469
- recordEditEvent(prev, next) {
470
- if (!this.fastPath.editHistoryEnabled)
471
- return;
472
- const { added, removed } = diffEditStrings(prev, next);
473
- if (!added && !removed)
474
- return;
475
- // SECURITY: pre-sanitize raw added/removed BEFORE tokenization.
476
- // Either-side hit drops the event entirely — never store partial
477
- // secrets via tokenized-half.
478
- if (containsSensitive(added) || containsSensitive(removed))
479
- return;
480
- const cursor = this.getCursor();
481
- this.fastPath.editHistory.record({
482
- uri: "prompt",
483
- rangeStartLine: cursor.line,
484
- addedTokens: tokenizeEdit(added),
485
- removedTokens: tokenizeEdit(removed),
486
- netCharDelta: added.length - removed.length,
487
- });
488
- }
489
- /**
490
- * Build the editProximity scorer bound to the current cursor + a
491
- * fresh `EditHistoryIndex` snapshot. Returns `null` when edit-history
492
- * is disabled OR the ring is empty (no signal to surface). Captured
493
- * per-prediction-cycle so the scorer is consistent across all
494
- * candidates in a single rerank.
495
- */
496
- buildEditProximityScorer() {
497
- if (!this.fastPath.editHistoryEnabled)
498
- return null;
499
- if (this.fastPath.editHistory.size === 0)
500
- return null;
501
- const index = this.fastPath.editHistory.index();
502
- const cursor = this.getCursor();
503
- const line = cursor.line;
504
- return (text) => computeEditProximityScore(text, index, "prompt", line);
505
- }
506
- render(width) {
507
- const lines = super.render(width);
508
- if (!this.shouldShowGhost())
509
- return lines;
510
- const ghost = this.ghostText;
511
- if (!ghost)
512
- return lines;
513
- // Replace-mode → `→ ` marker. Provisional (local guess while LLM
514
- // in-flight) → `◌ ` marker so the user sees this is a fast guess,
515
- // not the authoritative LLM result. Both prefixes share the same
516
- // dim color via the surrounding DIM_OPEN/DIM_CLOSE wrapper.
517
- const display = this.ghostReplaceFrom
518
- ? `→ ${ghost}`
519
- : this.fastPath.isProvisionalShown()
520
- ? `◌ ${ghost}`
521
- : ghost;
522
- for (let i = 0; i < lines.length; i++) {
523
- const line = lines[i];
524
- if (!line)
525
- continue;
526
- const idx = line.indexOf(CURSOR_MARKER);
527
- if (idx < 0)
528
- continue;
529
- const before = line.slice(0, idx + CURSOR_MARKER.length);
530
- const after = line.slice(idx + CURSOR_MARKER.length);
531
- const usedCols = visualWidth(line.slice(0, idx));
532
- // Reserve 1 column for cursor, 1 for safety margin.
533
- const remaining = width - usedCols - 2;
534
- if (remaining <= 0)
535
- return lines;
536
- const fitted = clipToWidth(display, remaining);
537
- if (!fitted)
538
- return lines;
539
- // Truncate to terminal width – the `after` portion contains padding that was
540
- // computed before ghost text insertion, so the combined line can exceed `width`.
541
- lines[i] = truncateToWidth(`${before}${DIM_OPEN}${fitted}${DIM_CLOSE}${after}`, width, "");
542
- break;
543
- }
544
- return lines;
545
- }
546
- cursorAtEnd() {
547
- const cursor = this.getCursor();
548
- const lines = this.getText().split("\n");
549
- if (cursor.line !== lines.length - 1)
550
- return false;
551
- const last = lines[lines.length - 1] ?? "";
552
- return cursor.col >= last.length;
553
- }
554
- shouldShowGhost() {
555
- if (!this.config.enabled)
556
- return false;
557
- if (!this.ghostText)
558
- return false;
559
- if (this.isShowingAutocomplete())
560
- return false;
561
- return this.getText() === this.ghostForText;
562
- }
563
- clearGhost() {
564
- this.cancelInflight();
565
- if (this.debounceTimer) {
566
- clearTimeout(this.debounceTimer);
567
- this.debounceTimer = null;
568
- }
569
- this.fastPath.cancelProvisionalTimer();
570
- if (this.ghostText !== null) {
571
- this.ghostText = null;
572
- this.ghostForText = "";
573
- this.ghostReplaceFrom = null;
574
- this.cycleMgr.reset();
575
- this.fastPath.clearProvisional();
576
- this.invalidate();
577
- }
578
- }
579
- cancelInflight() {
580
- if (this.inflight) {
581
- this.inflight.abort();
582
- this.inflight = null;
583
- }
584
- }
585
- /**
586
- * #5 — track consecutive divergent stales on the same prefix family.
587
- * A non-divergent (i.e., typed-through) stale resets the run because
588
- * the user was on-track with the suggestion. At threshold we apply a
589
- * trie penalty against the original `(prefix, ghost)` and stamp the
590
- * current `shownMetric` so the resulting stale record carries the
591
- * `typedPastDivergent` flag. Only one penalty per run — the run resets
592
- * so a long string of divergent typing doesn't double-charge.
593
- */
594
- recordDivergentTyped(divergent, prefix, ghost) {
595
- if (!divergent) {
596
- this.divergentRun = null;
597
- return;
598
- }
599
- const lastLine = prefix.split("\n").pop() ?? "";
600
- if (!lastLine || !ghost)
601
- return;
602
- const run = this.divergentRun;
603
- if (run && run.prefix === lastLine && run.ghost === ghost) {
604
- run.count++;
605
- }
606
- else {
607
- this.divergentRun = { prefix: lastLine, ghost, count: 1 };
608
- }
609
- const current = this.divergentRun;
610
- if (!current)
611
- return;
612
- if (current.count === TYPED_PAST_DIVERGENT_THRESHOLD) {
613
- this.fastPath.penalize(current.prefix, current.ghost, TYPED_PAST_TRIE_PENALTY);
614
- if (this.shownMetric)
615
- this.shownMetric.typedPastDivergent = true;
616
- this.divergentRun = null;
617
- }
618
- }
619
- handleTextChange(text) {
620
- if (this.disposed)
621
- return;
622
- if (this.ghostText && text !== this.ghostForText) {
623
- // PRD-adjacent enhancement (#5): typed-past divergent run.
624
- // Compute divergent-vs-typed-through BEFORE finalizeShown clears
625
- // `shownMetric`. A keystroke is "typed-through" iff the new text
626
- // equals `ghostForText + <prefix-of-ghost>` — i.e., the user is
627
- // typing the start of what we suggested. Anything else is a
628
- // divergent typed-past event and increments the run.
629
- const ghost = this.ghostText;
630
- const divergent = isTypedPastDivergent(ghost, this.ghostForText, text);
631
- const prefixForRun = this.shownMetric?.prefixAtShown ?? this.ghostForText;
632
- this.recordDivergentTyped(divergent, prefixForRun, ghost);
633
- this.finalizeShown("stale");
634
- this.clearGhost();
635
- }
636
- if (!this.config.enabled)
637
- return;
638
- if (!this.cursorAtEnd())
639
- return;
640
- // Deterministic path produces stable, source-of-truth ghosts (path
641
- // matches from `PathIndex`). Its `shownMetric.cacheKey` is intentionally
642
- // undefined → `finalizeShown` skips the cache-evict step. Re-typing a
643
- // rejected path-match would re-derive the same result anyway.
644
- if (this.tryDeterministic(text))
645
- return;
646
- // PRD §M4 — Slash command fast-path runs BEFORE the minChars gate and
647
- // the provider check: it's a local lookup that doesn't need an LLM
648
- // and benefits users at 1-char prefixes like `/`. The line-length cap
649
- // is also unnecessary here — slash commands are short by design.
650
- if (this.trySlashFastPath(text))
651
- return;
652
- if (!this.provider)
653
- return;
654
- const trimmed = text.trimEnd();
655
- const lastLine = trimmed.split("\n").pop() ?? "";
656
- if (stringWidth(lastLine) < this.config.minChars)
657
- return;
658
- if (stringWidth(lastLine) > this.config.maxLineLength)
659
- return;
660
- // Synchronous fast paths via FastPathEngine: trie, then cache, then
661
- // speculative warm. All three serve a ghost without firing the LLM
662
- // and without waiting for debounce. Trie comes first — reflects this
663
- // user's own confirmed history; only source whose hits cost zero $.
664
- const recent = this.buildSelectedContext(this.textBeforeCursor(text));
665
- const contextFingerprint = fingerprintRecentMessages(recent);
666
- const before = this.textBeforeCursor(text);
667
- const cooldownActive = this.provider instanceof RaceProvider
668
- ? this.provider.getCooldownState().active
669
- : false;
670
- const hit = this.fastPath.lookup(before, {
671
- contextFingerprint,
672
- providerFingerprint: this.providerFingerprint,
673
- versionFingerprint: this.versionFingerprint,
674
- cooldownActive,
675
- });
676
- if (hit) {
677
- // Cancel any in-flight LLM since its result would overwrite the
678
- // ghost we're about to show.
679
- this.cancelInflight();
680
- this.applyServedGhost(text, before, hit);
681
- return;
682
- }
683
- this.cancelInflight();
684
- if (this.debounceTimer)
685
- clearTimeout(this.debounceTimer);
686
- const debounce = this.provider.debounceMs;
687
- this.debounceTimer = setTimeout(() => {
688
- this.debounceTimer = null;
689
- void this.requestPrediction(text);
690
- }, debounce);
691
- }
692
- /**
693
- * Extension host hook: notify the editor that a user prompt has been
694
- * committed (Enter pressed, message submitted). Seeds the session trie
695
- * with prefix→remainder shingles from the committed text and reconciles
696
- * any in-flight accepted ghost against the actual commit (for divergent
697
- * dismiss accounting in a future iteration).
698
- *
699
- * Sanitization happens inside the trie. The hook is safe to call with raw
700
- * user text — secrets are filtered before insertion.
701
- */
702
- onPromptCommitted(text) {
703
- const accepted = this.lastAcceptedGhost;
704
- this.lastAcceptedGhost = null;
705
- seedTrieFromCommit(this.fastPath.trie, text, accepted);
706
- // PRD §M4 capture point: emit path/slash/trigram records. Sanitization
707
- // happens inside `captureCommit`; sensitive messages drop entirely.
708
- this.profile?.captureCommit(text);
709
- // PRD §M5a "accept-then-Enter-immediately (not regret)": user
710
- // committed the prompt — the accepted suggestion was kept and
711
- // sent. Flush the pending record with regret=false regardless of
712
- // the post-commit buffer state (which is typically empty and
713
- // would otherwise look like a full deletion).
714
- this.resolvePendingRegret({ flush: true, regret: false, deletedChars: 0 });
715
- // PRD §M5c capture point: emit eval examples for offline replay.
716
- // Opt-in via `PI_GHOST_EVAL_CAPTURE=1`; raw prompt + context land on
717
- // disk, so off by default. Sanitization runs inside
718
- // `buildExamplesFromCommit` (sensitive commits/contexts dropped).
719
- if (parseEvalCaptureEnv(this.fastPath.config.evalCaptureRaw)) {
720
- const context = renderContextForEval(this.buildSelectedContext(this.lastSingleLinePrefix()));
721
- const examples = buildExamplesFromCommit(text, {
722
- context,
723
- providerFingerprint: this.providerFingerprint,
724
- versionFingerprint: this.versionFingerprint,
725
- providerName: this.provider?.name ?? "none",
726
- });
727
- if (examples.length > 0) {
728
- void appendExamples(this.extCtx.cwd, examples);
729
- }
730
- }
731
- }
732
- /**
733
- * Single-line prefix at the cursor, used as the trie key. Stripping
734
- * earlier lines keeps trie entries small and keeps "prefix" semantics
735
- * consistent across single-line commits vs multi-line edits.
736
- */
737
- lastSingleLinePrefix() {
738
- const cursor = this.getCursor();
739
- const lines = this.getText().split("\n");
740
- const line = lines[cursor.line] ?? "";
741
- return line.slice(0, cursor.col);
742
- }
743
- /**
744
- * Assemble the recent-message context for a prediction. Combines:
745
- * - M6a: opt-in tool entries via `getRecentMessages({ includeTools })`
746
- * - M6c: type-aware summarization + cap on tool count
747
- * - M6b: relevance-pruned selection by BM25 + recency
748
- *
749
- * Returns oldest-first messages suitable for provider rendering. Sized
750
- * by the M6d env knobs; falls back to `config.provider.maxRecentMessages`
751
- * when context scanning is effectively disabled.
752
- */
753
- buildSelectedContext(prefix) {
754
- const ctxOpts = readContextEnv();
755
- const toolMax = parseToolMaxEnv(this.fastPath.config.contextToolMaxRaw);
756
- const includeTools = toolMax > 0;
757
- // Pull a wide pool (max scan), then summarize tool entries and pass
758
- // through selectContext. Convert newest-first by reversing the
759
- // oldest-first output from getRecentMessages.
760
- const pool = getRecentMessages(this.extCtx, ctxOpts.maxScan, { includeTools });
761
- const summarized = summarizeAndCapTools(pool, toolMax);
762
- // `selectContext` expects newest-first input. Reverse before passing in.
763
- const newestFirst = [...summarized].reverse();
764
- const selected = selectContext(newestFirst, prefix, {
765
- maxScan: ctxOpts.maxScan,
766
- maxSelected: ctxOpts.maxSelected,
767
- maxChars: ctxOpts.maxChars,
768
- bm25Weight: ctxOpts.bm25Weight,
769
- });
770
- // `selectContext` returns oldest-first — directly usable. Apply
771
- // SEC-15 redaction to every entry before it leaves the process:
772
- // recent messages include tool output that may carry API keys,
773
- // JWTs, or other high-entropy secrets pasted into the session.
774
- // `redactSensitive` is best-effort — see `trie-sanitize.ts` and
775
- // the security review for the threshold caveats.
776
- return selected.candidates.map((c) => ({
777
- role: c.role,
778
- text: redactSensitive(c.text),
779
- ...(c.toolName !== undefined ? { toolName: c.toolName } : {}),
780
- }));
781
- }
782
- textBeforeCursor(text) {
783
- const cursor = this.getCursor();
784
- const lines = text.split("\n");
785
- const headLines = lines.slice(0, cursor.line);
786
- const partial = (lines[cursor.line] ?? "").slice(0, cursor.col);
787
- return [...headLines, partial].join("\n");
788
- }
789
- applyServedGhost(text, before, warm) {
790
- const startedAt = Date.now();
791
- this.setShownGhost(text, warm.ghost, null);
792
- if (this.metrics) {
793
- this.shownMetric = {
794
- requestId: this.metrics.newRequestId(),
795
- startedAt,
796
- shownAt: startedAt,
797
- providerName: warm.providerName,
798
- mode: warm.mode,
799
- latencyMs: 0,
800
- completion: warm.ghost,
801
- cacheKey: warm.cacheKey,
802
- prefixAtShown: before,
803
- };
804
- }
805
- this.invalidate();
806
- }
807
- /**
808
- * Shared ghost-state mutation used by every "show this ghost now" path
809
- * (served, deterministic). Sets the 7 ghost-related fields atomically.
810
- * `replaceFrom` is null for append-mode ghosts; non-null for fuzzy/path
811
- * replace-mode. Does NOT touch `shownMetric` — callers build that with
812
- * source-specific fields.
813
- */
814
- setShownGhost(text, ghost, replaceFrom) {
815
- this.ghostText = ghost;
816
- this.ghostForText = text;
817
- this.ghostReplaceFrom = replaceFrom;
818
- this.cycleMgr.setCandidates([ghost]);
819
- }
820
- /**
821
- * PRD §M4 — slash-command fast-path. Runs before the minChars gate and
822
- * before the provider check: profile-local lookup, no LLM, useful even
823
- * at 1-char prefixes (`/`). Returns true when a slash-completion was
824
- * served. Profile must exist; line must start with `/`.
825
- */
826
- trySlashFastPath(text) {
827
- if (!this.profile)
828
- return false;
829
- const before = this.textBeforeCursor(text);
830
- const lastLine = before.split("\n").pop() ?? "";
831
- if (!lastLine.startsWith("/"))
832
- return false;
833
- const slashHit = this.profile.bestSlashMatch(lastLine);
834
- if (!slashHit)
835
- return false;
836
- const suffix = slashHit.cmd.slice(lastLine.length);
837
- if (suffix.length === 0)
838
- return false;
839
- this.cancelInflight();
840
- this.applyServedGhost(text, before, {
841
- ghost: suffix,
842
- mode: "profile-slash",
843
- providerName: "profile-slash",
844
- cacheKey: undefined,
845
- });
846
- return true;
847
- }
848
- tryDeterministic(text) {
849
- const pathIndex = this.fastPath.getPathIndex();
850
- if (!pathIndex)
851
- return false;
852
- if (this.isShowingAutocomplete())
853
- return false;
854
- const cursor = this.getCursor();
855
- const lines = text.split("\n");
856
- const lineText = lines[cursor.line] ?? "";
857
- const startedAt = Date.now();
858
- // PRD §M4 — pass `profilePathBoost` to both deterministic matchers so
859
- // historically-frequent paths break ties between equally-fresh
860
- // candidates. Bound by `MAX_PATH_BOOST` (0.2) inside the profile
861
- // store; cannot beat subtree/recent signals.
862
- //
863
- // Capture into a local so a concurrent `setProfile(null)` between
864
- // closure construction and invocation can't crash matcher calls.
865
- const profile = this.profile;
866
- const profileBoost = profile
867
- ? (path) => profile.profilePathBoost(path)
868
- : null;
869
- const decision = decideDeterministic({
870
- pathIndex,
871
- lineText,
872
- cursorLine: cursor.line,
873
- cursorCol: cursor.col,
874
- recentPaths: this.getRecentPaths(),
875
- profileBoost,
876
- });
877
- if (!decision)
878
- return false;
879
- this.applyDeterministic(text, decision.ghost, decision.replaceFrom, decision.providerName, decision.matchedPath, startedAt);
880
- return true;
881
- }
882
- getRecentPaths() {
883
- const now = Date.now();
884
- if (now - this.recentPathsAt < RECENT_PATHS_TTL_MS)
885
- return this.recentPaths;
886
- const msgs = getRoleWeightedRecent(this.extCtx, RECENT_USER_MAX, RECENT_ASSISTANT_MAX);
887
- this.recentPaths = extractRecentPaths(msgs);
888
- this.recentPathsAt = now;
889
- return this.recentPaths;
890
- }
891
- applyDeterministic(text, ghost, replaceFrom, providerName, matchedPath, startedAt) {
892
- this.setShownGhost(text, ghost, replaceFrom);
893
- if (this.metrics) {
894
- const now = Date.now();
895
- this.shownMetric = {
896
- requestId: this.metrics.newRequestId(),
897
- startedAt,
898
- shownAt: now,
899
- providerName,
900
- mode: "deterministic",
901
- latencyMs: now - startedAt,
902
- completion: ghost,
903
- matchedPath,
904
- };
905
- }
906
- this.invalidate();
907
- }
908
- replaceTokenAtCursor(from, replacement) {
909
- const cur = this.getCursor();
910
- // Deterministic trigger required cursorAtEnd; the token sits at the end
911
- // of the current (last) line, so `from.col..cur.col` is the slice to
912
- // replace and there is no trailing text to preserve.
913
- if (cur.line !== from.line) {
914
- // Should not happen given trigger constraints; fall back to append.
915
- this.insertTextAtCursor(replacement);
916
- return;
917
- }
918
- this.setText(computeReplacement(this.getText(), from, replacement));
919
- }
920
- /**
921
- * PRD §M3.5b — arm a provisional ghost. Engine owns the timer + env
922
- * gate + picker construction (uses its own trie/lastGhosts plus the
923
- * supplied profile). Editor supplies the request-scoped `canRender`
924
- * predicate and the `onShow` UI mutator — both close over `ctrl` +
925
- * `textAtSchedule` so they stay consistent with the in-flight request.
926
- */
927
- armProvisional(input) {
928
- const { textAtSchedule, ctrl, provider } = input;
929
- this.fastPath.armProvisional({
930
- prefix: textAtSchedule,
931
- debounceMs: provider.debounceMs,
932
- profile: this.profile,
933
- canRender: () => !this.disposed &&
934
- this.inflight === ctrl &&
935
- !this.ghostText &&
936
- this.getText() === textAtSchedule &&
937
- this.cursorAtEnd(),
938
- onShow: (pick) => {
939
- const beforeNow = this.textBeforeCursor(textAtSchedule);
940
- this.applyServedGhost(textAtSchedule, beforeNow, {
941
- ghost: pick.text,
942
- mode: "provisional",
943
- providerName: `provisional-${pick.source}`,
944
- cacheKey: undefined,
945
- });
946
- },
947
- });
948
- }
949
- async requestPrediction(textAtSchedule) {
950
- if (this.disposed || !this.provider)
951
- return;
952
- if (this.getText() !== textAtSchedule)
953
- return;
954
- const providerAtSchedule = this.provider;
955
- const ctrl = new AbortController();
956
- this.inflight = ctrl;
957
- this.armProvisional({ textAtSchedule, ctrl, provider: providerAtSchedule });
958
- const reqCtx = this.buildPredictionContext(textAtSchedule, providerAtSchedule, ctrl);
959
- const { pending, beforeAtSchedule, contextFingerprint, cacheKey, reqArgs } = reqCtx;
960
- try {
961
- // PRD §M3.5a — incremental render: paint each chunk as it arrives
962
- // so the user sees text growing live. Skipped when a provisional
963
- // is active — `decideSwap` reconciles once at stream end and
964
- // mid-stream painting would cause repeated visual jumps.
965
- //
966
- // `shownMetric` is NOT installed mid-stream. A Right-arrow accept
967
- // that fires before the stream ends still WORKS visually (inserts
968
- // whatever's painted) but produces no shown-metric record.
969
- // Acceptable rare case; installing `shownMetric` early would race
970
- // with the catch-block's abort path and double-emit on mid-stream
971
- // cancel.
972
- const provisionalActive = this.fastPath.isProvisionalShown();
973
- const canRender = () => !provisionalActive &&
974
- this.inflight === ctrl &&
975
- !this.disposed &&
976
- this.getText() === textAtSchedule &&
977
- this.cursorAtEnd();
978
- const onChunk = (cumulative) => {
979
- if (!canRender())
980
- return;
981
- this.ghostText = cumulative;
982
- this.ghostForText = textAtSchedule;
983
- this.cycleMgr.setCandidates([cumulative]);
984
- this.invalidate();
985
- };
986
- const startedAt = pending?.startedAt ?? Date.now();
987
- const { set, ttftMs } = await this.runPredictionPath(providerAtSchedule, reqArgs, startedAt, onChunk);
988
- this.finalizeProviderResult({
989
- set,
990
- ttftMs,
991
- pending,
992
- ctrl,
993
- providerAtSchedule,
994
- textAtSchedule,
995
- beforeAtSchedule,
996
- contextFingerprint,
997
- cacheKey,
998
- });
999
- }
1000
- catch (err) {
1001
- if (this.inflight === ctrl)
1002
- this.inflight = null;
1003
- const latencyMs = pending ? Date.now() - pending.startedAt : 0;
1004
- const aborted = err?.name === "AbortError";
1005
- const trace = providerAtSchedule instanceof RaceProvider ? providerAtSchedule.consumeLastTrace() : null;
1006
- this.emitMetric(pending, aborted ? "aborted" : "error", undefined, latencyMs, undefined, trace);
1007
- if (!aborted && this.onError && err instanceof Error) {
1008
- this.onError(err);
1009
- }
1010
- }
1011
- }
1012
- /**
1013
- * Refresh context + fingerprints + build the provider request bag at
1014
- * fire-time. Session messages may have arrived during the debounce
1015
- * window; the cache key we persist must match what the provider actually
1016
- * sees, otherwise a later identical keystroke would miss its own cached
1017
- * result. Pure context construction — no LLM call.
1018
- */
1019
- buildPredictionContext(textAtSchedule, providerAtSchedule, ctrl) {
1020
- const recent = this.buildSelectedContext(this.textBeforeCursor(textAtSchedule));
1021
- const contextFingerprint = fingerprintRecentMessages(recent);
1022
- const beforeAtSchedule = this.textBeforeCursor(textAtSchedule);
1023
- const cacheKey = makeKey({
1024
- prefix: beforeAtSchedule,
1025
- contextFingerprint,
1026
- providerFingerprint: this.providerFingerprint,
1027
- configFingerprint: this.fastPath.getConfigFingerprint(),
1028
- });
1029
- const pending = this.metrics
1030
- ? {
1031
- requestId: this.metrics.newRequestId(),
1032
- startedAt: Date.now(),
1033
- providerName: providerAtSchedule.name,
1034
- mode: "llm",
1035
- cacheKey,
1036
- prefixAtShown: beforeAtSchedule,
1037
- }
1038
- : null;
1039
- const reqArgs = {
1040
- editorText: textAtSchedule,
1041
- editorTextBeforeCursor: beforeAtSchedule,
1042
- recentMessages: recent,
1043
- cwd: this.extCtx.cwd,
1044
- signal: ctrl.signal,
1045
- };
1046
- return { pending, beforeAtSchedule, contextFingerprint, cacheKey, reqArgs };
1047
- }
1048
- /**
1049
- * Post-provider-call disposition: stamp TTFT, run the post-flight gate,
1050
- * sanitize empty results, rerank, apply confidence gate, reconcile with
1051
- * any displayed provisional, and commit. Each early-return emits the
1052
- * appropriate metric; the happy path lands in `commitDisplayedGhost`.
1053
- *
1054
- * Extracted from `requestPrediction` as part of refactor #57 phase 1 so
1055
- * the orchestrator stays a thin sequence (build → await → finalize).
1056
- */
1057
- finalizeProviderResult(input) {
1058
- const { set, ttftMs, pending, ctrl, providerAtSchedule, textAtSchedule, beforeAtSchedule, contextFingerprint, cacheKey, } = input;
1059
- if (pending && ttftMs !== undefined)
1060
- pending.ttftMs = ttftMs;
1061
- const result = set?.candidates[0]?.text ?? null;
1062
- const latencyMs = pending ? Date.now() - pending.startedAt : 0;
1063
- const trace = providerAtSchedule instanceof RaceProvider ? providerAtSchedule.consumeLastTrace() : null;
1064
- const abortReason = this.postFlightCheck(ctrl, providerAtSchedule, textAtSchedule);
1065
- if (abortReason === "aborted") {
1066
- this.emitMetric(pending, "aborted", undefined, latencyMs, undefined, trace);
1067
- return;
1068
- }
1069
- if (abortReason === "stale-text") {
1070
- this.emitMetric(pending, "rejected", undefined, latencyMs, "stale-text", trace);
1071
- return;
1072
- }
1073
- if (!result || !set) {
1074
- this.emitMetric(pending, "empty", undefined, latencyMs, undefined, trace);
1075
- return;
1076
- }
1077
- // PRD §M3c / §M3c.1 / §M4 — extracted to `candidate-rerank.ts` for
1078
- // isolation testing. Confidence gate is opt-in via
1079
- // `PI_GHOST_MIN_CONFIDENCE`; default preserves "always show top".
1080
- const profileForRerank = this.profile;
1081
- const editProximityScorer = this.buildEditProximityScorer();
1082
- const editHistorySize = this.fastPath.editHistory.size;
1083
- const reranked = rerankCandidateSet({
1084
- candidates: set.candidates,
1085
- providerName: providerAtSchedule.name,
1086
- prefix: beforeAtSchedule,
1087
- weightsEnvRaw: this.fastPath.config.rerankWeightsRaw,
1088
- minConfidenceEnvRaw: this.fastPath.config.minConfidenceRaw,
1089
- ...(profileForRerank
1090
- ? { profileBias: (t) => profileForRerank.profileBias(t) }
1091
- : {}),
1092
- ...(editProximityScorer ? { editProximityScorer } : {}),
1093
- });
1094
- if (reranked.gated) {
1095
- this.emitMetric(pending, "rejected", undefined, latencyMs, undefined, trace);
1096
- return;
1097
- }
1098
- const { candidateTexts, topText } = reranked;
1099
- // Enhancement #1 — stash the rerank-time edit-history observability
1100
- // fields on `pending` so the final shown/accepted record carries
1101
- // them. Skipped when scorer was disabled (size=0 or env off).
1102
- if (pending && editProximityScorer) {
1103
- pending.editProximityScore = reranked.topEditProximityScore;
1104
- pending.editHistorySize = editHistorySize;
1105
- }
1106
- // PRD §M3.5b — reconcile against any displayed provisional. The
1107
- // reranker's top score (if multi-candidate) feeds the LLM side;
1108
- // single-candidate sets use a neutral 0.5 since no rerank score
1109
- // exists. The manager owns timer cancel + state cleanup.
1110
- const llmScore = set.candidates.length > 1 ? 0.55 : 0.5;
1111
- const reconciled = this.fastPath.reconcileProvisional({
1112
- llm: { text: topText, score: llmScore },
1113
- now: Date.now(),
1114
- });
1115
- if (reconciled.hadProvisional && reconciled.decision.action === "keep") {
1116
- // Provisional was displayed and the LLM lost the swap-margin
1117
- // check. Keep what's on screen; discard LLM.
1118
- this.emitMetric(pending, "produced", topText, latencyMs, undefined, trace);
1119
- return;
1120
- }
1121
- // `extend` is the only branch where `decision.text` differs from
1122
- // `topText` (longer of provisional/LLM). `swap` ⇒ decision.text ===
1123
- // topText; `keep` ⇒ already returned above; no-provisional `swap`
1124
- // ⇒ decision.text === topText.
1125
- const displayText = reconciled.decision.action === "extend" ? reconciled.decision.text : topText;
1126
- this.commitDisplayedGhost({
1127
- displayText,
1128
- candidateTexts,
1129
- textAtSchedule,
1130
- beforeAtSchedule,
1131
- cacheKey,
1132
- contextFingerprint,
1133
- providerName: providerAtSchedule.name,
1134
- pending,
1135
- latencyMs,
1136
- trace,
1137
- displaced: reconciled.displaced,
1138
- });
1139
- }
1140
- /**
1141
- * Centralized post-provider-call disposition. Returns `null` when the
1142
- * result is good to render; otherwise the abort reason the caller should
1143
- * report via `emitMetric`. Clears `this.inflight` only on the happy path
1144
- * (matches the prior in-line behavior at the first non-aborted check) so
1145
- * an `aborted` return leaves `inflight` for the catch / next request to
1146
- * deal with.
1147
- */
1148
- postFlightCheck(ctrl, providerAtSchedule, textAtSchedule) {
1149
- if (this.inflight !== ctrl)
1150
- return "aborted";
1151
- this.inflight = null;
1152
- if (this.disposed)
1153
- return "aborted";
1154
- if (this.provider !== providerAtSchedule)
1155
- return "aborted";
1156
- if (this.getText() !== textAtSchedule)
1157
- return "stale-text";
1158
- return null;
1159
- }
1160
- /**
1161
- * PRD §M3.5a — invoke the provider in either streaming or multi-candidate
1162
- * mode and (when streaming) drive incremental UI paint via `onChunk`.
1163
- * Stamps TTFT on the first stream chunk and returns it for the caller to
1164
- * attach to the pending metric.
1165
- *
1166
- * - Streaming path: provider exposes `predictStream` AND lacks
1167
- * `predictMulti`. Used by Mercury FIM streaming + LocalProvider.
1168
- * - Multi path: everything else (openai-compat FIM, race with sync
1169
- * members). `onChunk` is ignored.
1170
- *
1171
- * Extracted from `requestPrediction` as part of refactor #57 so the
1172
- * orchestrator stays focused on rerank / reconcile / commit.
1173
- */
1174
- async runPredictionPath(provider, reqArgs, startedAt, onChunk) {
1175
- if (provider.predictStream && !provider.predictMulti) {
1176
- let ttftMs;
1177
- let lastCumulative = "";
1178
- for await (const chunk of wrapAsStream(provider, reqArgs)) {
1179
- if (chunk.firstChunk && ttftMs === undefined) {
1180
- ttftMs = Date.now() - startedAt;
1181
- }
1182
- lastCumulative = chunk.cumulative;
1183
- onChunk(chunk.cumulative);
1184
- }
1185
- const set = lastCumulative
1186
- ? { candidates: [{ text: lastCumulative }] }
1187
- : null;
1188
- return ttftMs !== undefined ? { set, ttftMs } : { set };
1189
- }
1190
- const set = await wrapAsMulti(provider, reqArgs);
1191
- return { set };
1192
- }
1193
- /**
1194
- * Commit a successfully-reconciled LLM ghost to all on-screen + fast-path
1195
- * structures: ranked candidate list, ghost text, cache, speculative ring,
1196
- * and the pending metric record. Extracted from `requestPrediction` as
1197
- * part of refactor #57 to keep the orchestrator readable.
1198
- *
1199
- * Cache + speculative ring store the *reranked* top — a subsequent cache
1200
- * hit must reproduce what the user actually saw, not the provider's
1201
- * pre-rerank order.
1202
- */
1203
- commitDisplayedGhost(input) {
1204
- // PRD §M3e — preserve the full ranked list for cycle keys.
1205
- this.cycleMgr.setCandidates(input.candidateTexts);
1206
- this.ghostText = input.displayText;
1207
- this.ghostForText = input.textAtSchedule;
1208
- const now = Date.now();
1209
- const configFingerprint = this.fastPath.getConfigFingerprint();
1210
- this.fastPath.setCacheEntry(input.cacheKey, {
1211
- ghost: input.displayText,
1212
- ts: now,
1213
- providerFingerprint: this.providerFingerprint,
1214
- contextFingerprint: input.contextFingerprint,
1215
- versionFingerprint: this.versionFingerprint,
1216
- configFingerprint,
1217
- providerName: input.providerName,
1218
- });
1219
- this.fastPath.recordRecentGhost({
1220
- prefix: input.beforeAtSchedule,
1221
- ghost: input.displayText,
1222
- ts: now,
1223
- contextFingerprint: input.contextFingerprint,
1224
- providerFingerprint: this.providerFingerprint,
1225
- versionFingerprint: this.versionFingerprint,
1226
- configFingerprint,
1227
- providerName: input.providerName,
1228
- });
1229
- if (input.pending) {
1230
- input.pending.completion = input.displayText;
1231
- input.pending.latencyMs = input.latencyMs;
1232
- input.pending.trace = input.trace;
1233
- input.pending.shownAt = now;
1234
- if (input.displaced) {
1235
- input.pending.provisionalSwapMs = input.displaced.provisionalSwapMs;
1236
- input.pending.provisionalWasCorrect = input.displaced.provisionalWasCorrect;
1237
- if (input.displaced.swapWasVisualJump)
1238
- input.pending.swapRenderedAt = now;
1239
- }
1240
- this.shownMetric = input.pending;
1241
- }
1242
- this.invalidate();
1243
- }
1244
- emitMetric(pending, result, completion, latencyMs, rejectionReason, trace) {
1245
- if (!this.metrics || !pending)
1246
- return;
1247
- // When a race trace exists, trust it verbatim — even an empty
1248
- // `providers: []` (e.g. cooldown skip) is meaningful: no members ran.
1249
- // Synthesizing a synthetic ghost-race entry here would otherwise
1250
- // attribute "empty" results to the race name and skew bench stats.
1251
- const providers = trace
1252
- ? trace.providers
1253
- : [
1254
- {
1255
- provider: pending.providerName,
1256
- latencyMs,
1257
- result,
1258
- rejectionReason,
1259
- },
1260
- ];
1261
- this.metrics.logMetric({
1262
- ts: Date.now(),
1263
- requestId: pending.requestId,
1264
- mode: pending.mode,
1265
- providers,
1266
- winner: trace?.winner ?? (result === "produced" ? pending.providerName : undefined),
1267
- result,
1268
- rejectionReason,
1269
- completionLength: completion?.length,
1270
- shown: false,
1271
- });
1272
- }
1273
- finalizeShown(outcome, regretSnapshot) {
1274
- const m = this.shownMetric;
1275
- this.shownMetric = null;
1276
- if (!m) {
1277
- // Still need to manage the regret-flush invariant even when there's
1278
- // no pending metric — the supersede semantics live with the outcome,
1279
- // not the metric.
1280
- const isAccept = outcome === "accepted" || outcome === "accepted-partial";
1281
- if (isAccept && this.pendingRegret)
1282
- this.flushRegret();
1283
- return;
1284
- }
1285
- const snap = this.cycleMgr.snapshot();
1286
- const built = buildShownMetricsRecord({
1287
- pending: m,
1288
- outcome,
1289
- now: Date.now(),
1290
- candidateCount: snap.count,
1291
- candidateIdx: snap.idx,
1292
- cycledCount: snap.cycled,
1293
- partialAccepts: snap.partials,
1294
- msPerChar: this.keystrokeTimer.msPerChar,
1295
- shortHoverThresholdMs: parseShortHoverEnv(this.fastPath.config.shortHoverMsRaw),
1296
- });
1297
- // On non-accept outcomes, treat the served ghost as poison for this exact
1298
- // key so an immediate retype doesn't re-serve the rejected ghost from
1299
- // cache. Speculative ring stays intact — its match branches are stricter
1300
- // and not the source of the rejected ghost in the common case.
1301
- //
1302
- // Both this and the trie penalty below fire regardless of whether a
1303
- // `metrics` logger is wired up — they are session-state corrections,
1304
- // not telemetry. (Pre-slice-3 the trie path was accidentally gated on
1305
- // `metrics != null`; the new ordering is the intended behavior.)
1306
- if (built.shouldEvictCache && m.cacheKey)
1307
- this.fastPath.deleteCacheEntry(m.cacheKey);
1308
- // Enhancement #3 — short-hover-dismiss trie penalty. Trie keys on the
1309
- // last single-line slice (matches insert path in `onPromptCommitted`).
1310
- if (built.shortHoverPenalty) {
1311
- this.fastPath.penalize(built.shortHoverPenalty.lastLine, built.shortHoverPenalty.completion, SHORT_HOVER_TRIE_PENALTY);
1312
- }
1313
- // A new accept supersedes any in-flight regret window from a
1314
- // previous accept. Flush the prior record before staging this one.
1315
- // Non-accept outcomes (stale/dismissed/expired) must NOT flush the
1316
- // prior accept's window — that would cut the regret-detection
1317
- // window short and miss late regrets within `windowMs`.
1318
- if (built.isAccept && this.pendingRegret)
1319
- this.flushRegret();
1320
- if (!this.metrics)
1321
- return;
1322
- const record = built.record;
1323
- // An accept-outcome resets the divergent-typed run; the user did
1324
- // converge on a useful suggestion eventually. Non-accept outcomes
1325
- // leave the run intact so a sequence of stales + a one-off dismiss
1326
- // inside it still counts toward the threshold.
1327
- if (built.isAccept)
1328
- this.divergentRun = null;
1329
- // PRD §M5a regret window: for accepts, stash the record + arm the
1330
- // tracker. Actual `logMetric` fires when regret resolves (timer
1331
- // or detected). Non-accept outcomes log immediately.
1332
- if (regretSnapshot &&
1333
- (outcome === "accepted" || outcome === "accepted-partial")) {
1334
- const windowMs = parseRegretWindowEnv(this.fastPath.config.regretWindowMsRaw);
1335
- this.pendingRegret = armRegret(record, regretSnapshot, Date.now(), windowMs);
1336
- this.regretTimer = setTimeout(() => this.flushRegret(), windowMs);
1337
- return;
1338
- }
1339
- this.metrics.logMetric(record);
1340
- }
1341
- /**
1342
- * Detach + clear the pending-regret watch (handle + timer) atomically
1343
- * and return the captured handle. Callers decide how to resolve the
1344
- * outcome (`observeRegret`, `expireRegret`, or a forced no-regret on
1345
- * Enter-commit) and log the resulting record.
1346
- *
1347
- * Returns null when no watch was armed — callers can short-circuit.
1348
- */
1349
- takePendingRegret() {
1350
- const h = this.pendingRegret;
1351
- if (!h)
1352
- return null;
1353
- this.pendingRegret = null;
1354
- if (this.regretTimer) {
1355
- clearTimeout(this.regretTimer);
1356
- this.regretTimer = null;
1357
- }
1358
- return h;
1359
- }
1360
- resolvePendingRegret(outcome) {
1361
- const h = this.takePendingRegret();
1362
- if (!h)
1363
- return;
1364
- this.metrics?.logMetric(attachRegret(h, outcome));
1365
- }
1366
- /**
1367
- * Flush any pending regret-watched record. Called by the timer, by a
1368
- * later accept that supersedes the prior window, or by dispose.
1369
- * Detects regret against the *current* editor text via `expireRegret`
1370
- * so the final record reflects whatever the user did in the window.
1371
- */
1372
- flushRegret() {
1373
- const h = this.takePendingRegret();
1374
- if (!h)
1375
- return;
1376
- this.metrics?.logMetric(attachRegret(h, expireRegret(h, this.getText())));
1377
- }
1378
- }
1379
- /**
1380
- * Render a recent-messages array into a single string for eval capture
1381
- * (PRD §M5c). Format mirrors the provider context shape — `role: text`
1382
- * joined by newlines — so a CLI replay can feed it back to the same
1383
- * provider. Tool entries get a `tool ${name}:` label.
1384
- */
1385
- export function renderContextForEval(recent) {
1386
- if (recent.length === 0)
1387
- return "";
1388
- const out = [];
1389
- for (const m of recent) {
1390
- const label = m.role === "tool" && m.toolName ? `tool ${m.toolName}` : m.role;
1391
- out.push(`${label}: ${m.text}`);
1392
- }
1393
- return out.join("\n");
1394
- }
1395
- /**
1396
- * Build a stable provider fingerprint from the config. For race configs, fold
1397
- * each member into the `extras` map so member rotation invalidates cache.
1398
- */
1399
- export function providerFingerprintFromConfig(cfg) {
1400
- if (cfg.kind === "race") {
1401
- const extras = {
1402
- pauseMs: cfg.pauseMs,
1403
- cooldownMs: cfg.cooldownMs,
1404
- cutoffMs: cfg.cutoffMs,
1405
- };
1406
- cfg.members.forEach((m, i) => {
1407
- extras[`m${i}.kind`] = m.kind;
1408
- extras[`m${i}.model`] = m.modelId;
1409
- extras[`m${i}.temp`] = m.temperature;
1410
- extras[`m${i}.maxTokens`] = m.maxTokens;
1411
- if (m.kind === "openai-compat") {
1412
- extras[`m${i}.providerName`] = m.providerName;
1413
- extras[`m${i}.baseUrl`] = m.baseUrl;
1414
- }
1415
- else if (m.kind === "local") {
1416
- extras[`m${i}.baseUrl`] = m.baseUrl;
1417
- }
1418
- else if (m.kind === "cloud") {
1419
- extras[`m${i}.provider`] = m.provider;
1420
- }
1421
- });
1422
- // Note: For race configs, per-member temperature/maxTokens are stored in extras.
1423
- // These top-level fields are intentional placeholders for fingerprint stability.
1424
- return fingerprintProvider({
1425
- kind: "race",
1426
- providerName: "race",
1427
- modelId: cfg.members.map((m) => m.modelId).join("|"),
1428
- temperature: cfg.members[0]?.temperature ?? 0,
1429
- maxTokens: cfg.members[0]?.maxTokens ?? 0,
1430
- maxRecentMessages: cfg.maxRecentMessages,
1431
- extras,
1432
- });
1433
- }
1434
- const extras = {};
1435
- if (cfg.kind === "openai-compat")
1436
- extras.baseUrl = cfg.baseUrl;
1437
- else if (cfg.kind === "local")
1438
- extras.baseUrl = cfg.baseUrl;
1439
- else if (cfg.kind === "cloud")
1440
- extras.provider = cfg.provider;
1441
- return fingerprintProvider({
1442
- kind: cfg.kind,
1443
- providerName: cfg.kind === "openai-compat" ? cfg.providerName : cfg.kind,
1444
- modelId: cfg.modelId,
1445
- temperature: cfg.temperature,
1446
- maxTokens: cfg.maxTokens,
1447
- maxRecentMessages: cfg.maxRecentMessages,
1448
- extras,
1449
- });
1450
- }
1451
- /**
1452
- * Pure helper: replace the slice `[from.col, end-of-line]` on `from.line` of
1453
- * `text` with `replacement`. Exported for testability.
1454
- *
1455
- * Caller must guarantee the cursor sits at end of `from.line` and that the
1456
- * replacement is intended to land there; this helper does not validate.
1457
- */
1458
- export function computeReplacement(text, from, replacement) {
1459
- const lines = text.split("\n");
1460
- const line = lines[from.line] ?? "";
1461
- lines[from.line] = line.slice(0, from.col) + replacement;
1462
- return lines.join("\n");
1463
- }
1464
- /**
1465
- * Visible column width of a possibly-ANSI-colored string.
1466
- * Uses string-width which handles wide chars, combining marks, and emoji.
1467
- */
1468
- export function visualWidth(s) {
1469
- return stringWidth(stripAnsi(s));
1470
- }
1471
- /**
1472
- * Clip a string to fit in `maxCols` terminal columns, grapheme-aware.
1473
- * Returns the empty string if `maxCols <= 0` or no characters fit.
1474
- */
1475
- export function clipToWidth(s, maxCols) {
1476
- if (maxCols <= 0)
1477
- return "";
1478
- if (stringWidth(s) <= maxCols)
1479
- return s;
1480
- let out = "";
1481
- let used = 0;
1482
- for (const { segment } of GRAPHEME_SEGMENTER.segment(s)) {
1483
- const w = stringWidth(segment);
1484
- if (used + w > maxCols)
1485
- break;
1486
- out += segment;
1487
- used += w;
1488
- }
1489
- return out;
1490
- }
1491
- //# sourceMappingURL=ghost-editor.js.map