pi-ghost-autocomplete 0.3.1 → 0.3.2
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.
- package/README.md +101 -6
- package/dist/bench-cli.js +81 -5
- package/dist/bench-cli.js.map +1 -1
- package/dist/bench.d.ts +130 -2
- package/dist/bench.d.ts.map +1 -1
- package/dist/bench.js +285 -4
- package/dist/bench.js.map +1 -1
- package/dist/cache.d.ts +124 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +173 -0
- package/dist/cache.js.map +1 -0
- package/dist/candidate-rerank.d.ts +57 -0
- package/dist/candidate-rerank.d.ts.map +1 -0
- package/dist/candidate-rerank.js +78 -0
- package/dist/candidate-rerank.js.map +1 -0
- package/dist/config.d.ts +43 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +148 -18
- package/dist/config.js.map +1 -1
- package/dist/context-aggregator.d.ts +14 -5
- package/dist/context-aggregator.d.ts.map +1 -1
- package/dist/context-aggregator.js +38 -9
- package/dist/context-aggregator.js.map +1 -1
- package/dist/context-selector.d.ts +128 -0
- package/dist/context-selector.d.ts.map +1 -0
- package/dist/context-selector.js +300 -0
- package/dist/context-selector.js.map +1 -0
- package/dist/cycle-manager.d.ts +102 -0
- package/dist/cycle-manager.d.ts.map +1 -0
- package/dist/cycle-manager.js +168 -0
- package/dist/cycle-manager.js.map +1 -0
- package/dist/edit-history-ring.d.ts +148 -0
- package/dist/edit-history-ring.d.ts.map +1 -0
- package/dist/edit-history-ring.js +262 -0
- package/dist/edit-history-ring.js.map +1 -0
- package/dist/entropy-stop.d.ts +102 -0
- package/dist/entropy-stop.d.ts.map +1 -0
- package/dist/entropy-stop.js +141 -0
- package/dist/entropy-stop.js.map +1 -0
- package/dist/eval-dataset.d.ts +66 -0
- package/dist/eval-dataset.d.ts.map +1 -0
- package/dist/eval-dataset.js +127 -0
- package/dist/eval-dataset.js.map +1 -0
- package/dist/fast-path-engine.d.ts +286 -0
- package/dist/fast-path-engine.d.ts.map +1 -0
- package/dist/fast-path-engine.js +341 -0
- package/dist/fast-path-engine.js.map +1 -0
- package/dist/fuzzy-matcher.d.ts +8 -0
- package/dist/fuzzy-matcher.d.ts.map +1 -1
- package/dist/fuzzy-matcher.js +5 -3
- package/dist/fuzzy-matcher.js.map +1 -1
- package/dist/ghost-editor.d.ts +321 -4
- package/dist/ghost-editor.d.ts.map +1 -1
- package/dist/ghost-editor.js +1190 -144
- package/dist/ghost-editor.js.map +1 -1
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +81 -2
- package/dist/index.js.map +1 -1
- package/dist/keystroke-timer.d.ts +59 -0
- package/dist/keystroke-timer.d.ts.map +1 -0
- package/dist/keystroke-timer.js +94 -0
- package/dist/keystroke-timer.js.map +1 -0
- package/dist/metrics-builder.d.ts +71 -0
- package/dist/metrics-builder.d.ts.map +1 -0
- package/dist/metrics-builder.js +71 -0
- package/dist/metrics-builder.js.map +1 -0
- package/dist/metrics.d.ts +99 -2
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +131 -17
- package/dist/metrics.js.map +1 -1
- package/dist/path-matcher.d.ts +9 -0
- package/dist/path-matcher.d.ts.map +1 -1
- package/dist/path-matcher.js +5 -2
- package/dist/path-matcher.js.map +1 -1
- package/dist/profile-store.d.ts +82 -0
- package/dist/profile-store.d.ts.map +1 -0
- package/dist/profile-store.js +199 -0
- package/dist/profile-store.js.map +1 -0
- package/dist/profile.d.ts +185 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +434 -0
- package/dist/profile.js.map +1 -0
- package/dist/providers/cloud-provider.d.ts +10 -1
- package/dist/providers/cloud-provider.d.ts.map +1 -1
- package/dist/providers/cloud-provider.js +71 -14
- package/dist/providers/cloud-provider.js.map +1 -1
- package/dist/providers/local-provider.d.ts +8 -1
- package/dist/providers/local-provider.d.ts.map +1 -1
- package/dist/providers/local-provider.js +15 -3
- package/dist/providers/local-provider.js.map +1 -1
- package/dist/providers/openai-compat-provider.d.ts +11 -1
- package/dist/providers/openai-compat-provider.d.ts.map +1 -1
- package/dist/providers/openai-compat-provider.js +93 -6
- package/dist/providers/openai-compat-provider.js.map +1 -1
- package/dist/providers/openai-completions-runner.d.ts +25 -1
- package/dist/providers/openai-completions-runner.d.ts.map +1 -1
- package/dist/providers/openai-completions-runner.js +109 -14
- package/dist/providers/openai-completions-runner.js.map +1 -1
- package/dist/providers/race.d.ts +58 -1
- package/dist/providers/race.d.ts.map +1 -1
- package/dist/providers/race.js +317 -4
- package/dist/providers/race.js.map +1 -1
- package/dist/providers/truncate.d.ts.map +1 -1
- package/dist/providers/truncate.js +20 -2
- package/dist/providers/truncate.js.map +1 -1
- package/dist/providers/types.d.ts +96 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js +59 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/provisional-manager.d.ts +93 -0
- package/dist/provisional-manager.d.ts.map +1 -0
- package/dist/provisional-manager.js +98 -0
- package/dist/provisional-manager.js.map +1 -0
- package/dist/provisional.d.ts +150 -0
- package/dist/provisional.d.ts.map +1 -0
- package/dist/provisional.js +238 -0
- package/dist/provisional.js.map +1 -0
- package/dist/regret-tracker.d.ts +84 -0
- package/dist/regret-tracker.d.ts.map +1 -0
- package/dist/regret-tracker.js +145 -0
- package/dist/regret-tracker.js.map +1 -0
- package/dist/replay-cli.d.ts +13 -0
- package/dist/replay-cli.d.ts.map +1 -0
- package/dist/replay-cli.js +167 -0
- package/dist/replay-cli.js.map +1 -0
- package/dist/replay.d.ts +107 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +225 -0
- package/dist/replay.js.map +1 -0
- package/dist/reranker.d.ts +181 -0
- package/dist/reranker.d.ts.map +1 -0
- package/dist/reranker.js +324 -0
- package/dist/reranker.js.map +1 -0
- package/dist/session-trie.d.ts +103 -0
- package/dist/session-trie.d.ts.map +1 -0
- package/dist/session-trie.js +280 -0
- package/dist/session-trie.js.map +1 -0
- package/dist/speculative.d.ts +74 -0
- package/dist/speculative.d.ts.map +1 -0
- package/dist/speculative.js +107 -0
- package/dist/speculative.js.map +1 -0
- package/dist/tool-summarize.d.ts +40 -0
- package/dist/tool-summarize.d.ts.map +1 -0
- package/dist/tool-summarize.js +245 -0
- package/dist/tool-summarize.js.map +1 -0
- package/dist/trie-sanitize.d.ts +22 -0
- package/dist/trie-sanitize.d.ts.map +1 -0
- package/dist/trie-sanitize.js +142 -0
- package/dist/trie-sanitize.js.map +1 -0
- package/dist/versions.d.ts +23 -0
- package/dist/versions.d.ts.map +1 -0
- package/dist/versions.js +31 -0
- package/dist/versions.js.map +1 -0
- package/package.json +3 -2
package/dist/ghost-editor.js
CHANGED
|
@@ -2,14 +2,78 @@ import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { CURSOR_MARKER, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import stringWidth from "string-width";
|
|
4
4
|
import stripAnsi from "strip-ansi";
|
|
5
|
+
import { fingerprintProvider, fingerprintRecentMessages, makeKey, } from "./cache.js";
|
|
6
|
+
import { FastPathEngine, decideDeterministic } from "./fast-path-engine.js";
|
|
5
7
|
import { getRecentMessages } from "./context-aggregator.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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";
|
|
8
20
|
import { RaceProvider } from "./providers/race.js";
|
|
21
|
+
import { wrapAsMulti, wrapAsStream, } from "./providers/types.js";
|
|
9
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";
|
|
10
26
|
const RECENT_USER_MAX = 2;
|
|
11
27
|
const RECENT_ASSISTANT_MAX = 1;
|
|
12
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
|
+
}
|
|
13
77
|
const DIM_OPEN = "\x1b[2;90m";
|
|
14
78
|
const DIM_CLOSE = "\x1b[0m";
|
|
15
79
|
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
@@ -19,9 +83,18 @@ export class GhostEditor extends CustomEditor {
|
|
|
19
83
|
extCtx;
|
|
20
84
|
metrics;
|
|
21
85
|
onError;
|
|
22
|
-
|
|
86
|
+
profile;
|
|
23
87
|
ghostText = null;
|
|
24
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;
|
|
25
98
|
ghostReplaceFrom = null;
|
|
26
99
|
debounceTimer = null;
|
|
27
100
|
inflight = null;
|
|
@@ -30,18 +103,139 @@ export class GhostEditor extends CustomEditor {
|
|
|
30
103
|
shownMetric = null;
|
|
31
104
|
recentPaths = new Set();
|
|
32
105
|
recentPathsAt = 0;
|
|
33
|
-
|
|
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) {
|
|
34
124
|
super(tui, theme, keybindings);
|
|
35
125
|
this.provider = provider;
|
|
36
126
|
this.config = config;
|
|
37
127
|
this.extCtx = extCtx;
|
|
38
128
|
this.metrics = metrics;
|
|
39
|
-
this.pathIndex
|
|
129
|
+
this.fastPath.setPathIndex(pathIndex);
|
|
40
130
|
this.onError = onError;
|
|
131
|
+
this.profile = profile;
|
|
41
132
|
this.lastTextSeen = this.getText();
|
|
133
|
+
this.providerFingerprint = providerFingerprintFromConfig(config.provider);
|
|
42
134
|
}
|
|
43
135
|
setPathIndex(index) {
|
|
44
|
-
this.
|
|
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
|
+
};
|
|
45
239
|
}
|
|
46
240
|
setProvider(provider) {
|
|
47
241
|
this.provider = provider;
|
|
@@ -51,40 +245,263 @@ export class GhostEditor extends CustomEditor {
|
|
|
51
245
|
this.disposed = true;
|
|
52
246
|
this.finalizeShown("expired");
|
|
53
247
|
this.clearGhost();
|
|
248
|
+
// Flush any pending regret record BEFORE the metrics logger
|
|
249
|
+
// drains its queue.
|
|
250
|
+
this.flushRegret();
|
|
54
251
|
void this.metrics?.flush();
|
|
252
|
+
void this.profile?.flush();
|
|
55
253
|
}
|
|
56
254
|
handleInput(data) {
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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);
|
|
72
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);
|
|
73
368
|
this.lastTextSeen = this.getText();
|
|
74
|
-
return;
|
|
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;
|
|
75
395
|
}
|
|
76
|
-
|
|
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) {
|
|
77
424
|
this.finalizeShown("dismissed");
|
|
78
425
|
this.clearGhost();
|
|
79
426
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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() {
|
|
83
434
|
const text = this.getText();
|
|
84
|
-
if (text
|
|
85
|
-
|
|
86
|
-
|
|
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());
|
|
87
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);
|
|
88
505
|
}
|
|
89
506
|
render(width) {
|
|
90
507
|
const lines = super.render(width);
|
|
@@ -93,9 +510,15 @@ export class GhostEditor extends CustomEditor {
|
|
|
93
510
|
const ghost = this.ghostText;
|
|
94
511
|
if (!ghost)
|
|
95
512
|
return lines;
|
|
96
|
-
// Replace-mode
|
|
97
|
-
//
|
|
98
|
-
|
|
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;
|
|
99
522
|
for (let i = 0; i < lines.length; i++) {
|
|
100
523
|
const line = lines[i];
|
|
101
524
|
if (!line)
|
|
@@ -143,10 +566,13 @@ export class GhostEditor extends CustomEditor {
|
|
|
143
566
|
clearTimeout(this.debounceTimer);
|
|
144
567
|
this.debounceTimer = null;
|
|
145
568
|
}
|
|
569
|
+
this.fastPath.cancelProvisionalTimer();
|
|
146
570
|
if (this.ghostText !== null) {
|
|
147
571
|
this.ghostText = null;
|
|
148
572
|
this.ghostForText = "";
|
|
149
573
|
this.ghostReplaceFrom = null;
|
|
574
|
+
this.cycleMgr.reset();
|
|
575
|
+
this.fastPath.clearProvisional();
|
|
150
576
|
this.invalidate();
|
|
151
577
|
}
|
|
152
578
|
}
|
|
@@ -156,11 +582,54 @@ export class GhostEditor extends CustomEditor {
|
|
|
156
582
|
this.inflight = null;
|
|
157
583
|
}
|
|
158
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
|
+
}
|
|
159
619
|
handleTextChange(text) {
|
|
160
620
|
if (this.disposed)
|
|
161
621
|
return;
|
|
162
|
-
this.cancelInflight();
|
|
163
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);
|
|
164
633
|
this.finalizeShown("stale");
|
|
165
634
|
this.clearGhost();
|
|
166
635
|
}
|
|
@@ -168,8 +637,18 @@ export class GhostEditor extends CustomEditor {
|
|
|
168
637
|
return;
|
|
169
638
|
if (!this.cursorAtEnd())
|
|
170
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.
|
|
171
644
|
if (this.tryDeterministic(text))
|
|
172
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;
|
|
173
652
|
if (!this.provider)
|
|
174
653
|
return;
|
|
175
654
|
const trimmed = text.trimEnd();
|
|
@@ -178,6 +657,30 @@ export class GhostEditor extends CustomEditor {
|
|
|
178
657
|
return;
|
|
179
658
|
if (stringWidth(lastLine) > this.config.maxLineLength)
|
|
180
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();
|
|
181
684
|
if (this.debounceTimer)
|
|
182
685
|
clearTimeout(this.debounceTimer);
|
|
183
686
|
const debounce = this.provider.debounceMs;
|
|
@@ -186,39 +689,194 @@ export class GhostEditor extends CustomEditor {
|
|
|
186
689
|
void this.requestPrediction(text);
|
|
187
690
|
}, debounce);
|
|
188
691
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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) {
|
|
194
783
|
const cursor = this.getCursor();
|
|
195
784
|
const lines = text.split("\n");
|
|
196
|
-
const
|
|
197
|
-
const partial =
|
|
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) {
|
|
198
790
|
const startedAt = Date.now();
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
+
};
|
|
207
804
|
}
|
|
208
|
-
|
|
209
|
-
|
|
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)
|
|
210
828
|
return false;
|
|
211
|
-
const
|
|
212
|
-
|
|
829
|
+
const before = this.textBeforeCursor(text);
|
|
830
|
+
const lastLine = before.split("\n").pop() ?? "";
|
|
831
|
+
if (!lastLine.startsWith("/"))
|
|
213
832
|
return false;
|
|
214
|
-
|
|
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)
|
|
215
851
|
return false;
|
|
216
|
-
if (
|
|
852
|
+
if (this.isShowingAutocomplete())
|
|
217
853
|
return false;
|
|
218
|
-
const
|
|
219
|
-
|
|
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)
|
|
220
868
|
: null;
|
|
221
|
-
|
|
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);
|
|
222
880
|
return true;
|
|
223
881
|
}
|
|
224
882
|
getRecentPaths() {
|
|
@@ -231,17 +889,16 @@ export class GhostEditor extends CustomEditor {
|
|
|
231
889
|
return this.recentPaths;
|
|
232
890
|
}
|
|
233
891
|
applyDeterministic(text, ghost, replaceFrom, providerName, matchedPath, startedAt) {
|
|
234
|
-
|
|
235
|
-
this.ghostText = ghost;
|
|
236
|
-
this.ghostForText = text;
|
|
237
|
-
this.ghostReplaceFrom = replaceFrom;
|
|
892
|
+
this.setShownGhost(text, ghost, replaceFrom);
|
|
238
893
|
if (this.metrics) {
|
|
894
|
+
const now = Date.now();
|
|
239
895
|
this.shownMetric = {
|
|
240
896
|
requestId: this.metrics.newRequestId(),
|
|
241
897
|
startedAt,
|
|
898
|
+
shownAt: now,
|
|
242
899
|
providerName,
|
|
243
900
|
mode: "deterministic",
|
|
244
|
-
latencyMs,
|
|
901
|
+
latencyMs: now - startedAt,
|
|
245
902
|
completion: ghost,
|
|
246
903
|
matchedPath,
|
|
247
904
|
};
|
|
@@ -260,6 +917,35 @@ export class GhostEditor extends CustomEditor {
|
|
|
260
917
|
}
|
|
261
918
|
this.setText(computeReplacement(this.getText(), from, replacement));
|
|
262
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
|
+
}
|
|
263
949
|
async requestPrediction(textAtSchedule) {
|
|
264
950
|
if (this.disposed || !this.provider)
|
|
265
951
|
return;
|
|
@@ -268,60 +954,48 @@ export class GhostEditor extends CustomEditor {
|
|
|
268
954
|
const providerAtSchedule = this.provider;
|
|
269
955
|
const ctrl = new AbortController();
|
|
270
956
|
this.inflight = ctrl;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
startedAt: Date.now(),
|
|
275
|
-
providerName: providerAtSchedule.name,
|
|
276
|
-
mode: "llm",
|
|
277
|
-
}
|
|
278
|
-
: null;
|
|
957
|
+
this.armProvisional({ textAtSchedule, ctrl, provider: providerAtSchedule });
|
|
958
|
+
const reqCtx = this.buildPredictionContext(textAtSchedule, providerAtSchedule, ctrl);
|
|
959
|
+
const { pending, beforeAtSchedule, contextFingerprint, cacheKey, reqArgs } = reqCtx;
|
|
279
960
|
try {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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,
|
|
292
998
|
});
|
|
293
|
-
const latencyMs = pending ? Date.now() - pending.startedAt : 0;
|
|
294
|
-
const trace = providerAtSchedule instanceof RaceProvider ? providerAtSchedule.consumeLastTrace() : null;
|
|
295
|
-
if (this.inflight !== ctrl) {
|
|
296
|
-
this.emitMetric(pending, "aborted", undefined, latencyMs, undefined, trace);
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
this.inflight = null;
|
|
300
|
-
if (this.disposed) {
|
|
301
|
-
this.emitMetric(pending, "aborted", undefined, latencyMs, undefined, trace);
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (this.provider !== providerAtSchedule) {
|
|
305
|
-
this.emitMetric(pending, "aborted", undefined, latencyMs, undefined, trace);
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (this.getText() !== textAtSchedule) {
|
|
309
|
-
this.emitMetric(pending, "rejected", undefined, latencyMs, "stale-text", trace);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
if (!result) {
|
|
313
|
-
this.emitMetric(pending, "empty", undefined, latencyMs, undefined, trace);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
this.ghostText = result;
|
|
317
|
-
this.ghostForText = textAtSchedule;
|
|
318
|
-
if (pending) {
|
|
319
|
-
pending.completion = result;
|
|
320
|
-
pending.latencyMs = latencyMs;
|
|
321
|
-
pending.trace = trace;
|
|
322
|
-
this.shownMetric = pending;
|
|
323
|
-
}
|
|
324
|
-
this.invalidate();
|
|
325
999
|
}
|
|
326
1000
|
catch (err) {
|
|
327
1001
|
if (this.inflight === ctrl)
|
|
@@ -335,6 +1009,238 @@ export class GhostEditor extends CustomEditor {
|
|
|
335
1009
|
}
|
|
336
1010
|
}
|
|
337
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
|
+
}
|
|
338
1244
|
emitMetric(pending, result, completion, latencyMs, rejectionReason, trace) {
|
|
339
1245
|
if (!this.metrics || !pending)
|
|
340
1246
|
return;
|
|
@@ -364,43 +1270,183 @@ export class GhostEditor extends CustomEditor {
|
|
|
364
1270
|
shown: false,
|
|
365
1271
|
});
|
|
366
1272
|
}
|
|
367
|
-
finalizeShown(outcome) {
|
|
1273
|
+
finalizeShown(outcome, regretSnapshot) {
|
|
368
1274
|
const m = this.shownMetric;
|
|
369
1275
|
this.shownMetric = null;
|
|
370
|
-
if (!
|
|
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();
|
|
371
1283
|
return;
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
mode: m.mode,
|
|
385
|
-
providers,
|
|
386
|
-
winner: m.trace?.winner ?? m.providerName,
|
|
387
|
-
result: "produced",
|
|
388
|
-
completionLength: m.completion?.length,
|
|
389
|
-
shown: true,
|
|
390
|
-
acceptance: outcome,
|
|
391
|
-
matchedPath: m.matchedPath,
|
|
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),
|
|
392
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())));
|
|
393
1377
|
}
|
|
394
1378
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
});
|
|
403
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
|
+
});
|
|
404
1450
|
}
|
|
405
1451
|
/**
|
|
406
1452
|
* Pure helper: replace the slice `[from.col, end-of-line]` on `from.line` of
|