@atlaskit/editor-plugin-autocomplete 8.0.0 → 9.1.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 (79) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/dist/cjs/analytics/ufo.js +2 -1
  3. package/dist/cjs/pm-plugins/artifact-loader.js +135 -0
  4. package/dist/cjs/pm-plugins/artifacts-manifest.js +3 -0
  5. package/dist/cjs/pm-plugins/autocomplete-plugin.js +498 -160
  6. package/dist/cjs/pm-plugins/canonical-lm-scoring.js +371 -0
  7. package/dist/cjs/pm-plugins/debug-mode.js +105 -7
  8. package/dist/cjs/pm-plugins/ghost-text-decoration.js +12 -3
  9. package/dist/cjs/pm-plugins/local-slow-lane-client.js +1202 -453
  10. package/dist/cjs/pm-plugins/scoring-pipeline.js +164 -19
  11. package/dist/cjs/pm-plugins/slow-lane-client.js +100 -37
  12. package/dist/cjs/pm-plugins/text-predictor.js +2099 -309
  13. package/dist/es2019/analytics/ufo.js +2 -0
  14. package/dist/es2019/pm-plugins/artifact-loader.js +70 -0
  15. package/dist/es2019/pm-plugins/artifacts-manifest.js +3 -0
  16. package/dist/es2019/pm-plugins/autocomplete-plugin.js +489 -152
  17. package/dist/es2019/pm-plugins/canonical-lm-scoring.js +270 -0
  18. package/dist/es2019/pm-plugins/debug-mode.js +102 -7
  19. package/dist/es2019/pm-plugins/ghost-text-decoration.js +12 -3
  20. package/dist/es2019/pm-plugins/local-slow-lane-client.js +940 -326
  21. package/dist/es2019/pm-plugins/scoring-pipeline.js +137 -13
  22. package/dist/es2019/pm-plugins/slow-lane-client.js +94 -37
  23. package/dist/es2019/pm-plugins/text-predictor.js +1692 -200
  24. package/dist/esm/analytics/ufo.js +2 -1
  25. package/dist/esm/pm-plugins/artifact-loader.js +128 -0
  26. package/dist/esm/pm-plugins/artifacts-manifest.js +3 -0
  27. package/dist/esm/pm-plugins/autocomplete-plugin.js +501 -163
  28. package/dist/esm/pm-plugins/canonical-lm-scoring.js +364 -0
  29. package/dist/esm/pm-plugins/debug-mode.js +104 -6
  30. package/dist/esm/pm-plugins/ghost-text-decoration.js +12 -3
  31. package/dist/esm/pm-plugins/local-slow-lane-client.js +1200 -451
  32. package/dist/esm/pm-plugins/scoring-pipeline.js +163 -18
  33. package/dist/esm/pm-plugins/slow-lane-client.js +100 -37
  34. package/dist/esm/pm-plugins/text-predictor.js +2101 -313
  35. package/dist/types/analytics/ufo.d.ts +1 -0
  36. package/dist/types/pm-plugins/artifact-loader.d.ts +44 -0
  37. package/dist/types/pm-plugins/artifacts-manifest.d.ts +3 -0
  38. package/dist/types/pm-plugins/autocomplete-plugin.d.ts +39 -0
  39. package/dist/types/pm-plugins/canonical-lm-scoring.d.ts +158 -0
  40. package/dist/types/pm-plugins/debug-mode.d.ts +55 -1
  41. package/dist/types/pm-plugins/ghost-text-decoration.d.ts +5 -2
  42. package/dist/types/pm-plugins/local-slow-lane-client.d.ts +87 -54
  43. package/dist/types/pm-plugins/scoring-pipeline.d.ts +33 -1
  44. package/dist/types/pm-plugins/slow-lane-client.d.ts +51 -3
  45. package/dist/types/pm-plugins/text-predictor.d.ts +156 -3
  46. package/package.json +2 -2
  47. package/src/analytics/ufo.ts +5 -0
  48. package/src/pm-plugins/artifact-loader.ts +92 -0
  49. package/src/pm-plugins/artifacts-manifest.ts +3 -0
  50. package/src/pm-plugins/autocomplete-plugin.ts +584 -154
  51. package/src/pm-plugins/canonical-lm-scoring.ts +412 -0
  52. package/src/pm-plugins/debug-mode.ts +134 -7
  53. package/src/pm-plugins/ghost-text-decoration.ts +13 -4
  54. package/src/pm-plugins/local-slow-lane-client.ts +1302 -386
  55. package/src/pm-plugins/scoring-pipeline.ts +167 -17
  56. package/src/pm-plugins/slow-lane-client.ts +147 -62
  57. package/src/pm-plugins/text-predictor.ts +2119 -254
  58. package/CANONICAL_FIX__DO_NOT_USE_ME_A/package.json +0 -8
  59. package/CANONICAL_FIX__DO_NOT_USE_ME_B/package.json +0 -8
  60. package/CANONICAL_FIX__DO_NOT_USE_ME_C/package.json +0 -8
  61. package/dist/cjs/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -18
  62. package/dist/cjs/entry-points/src-pm-plugins-slow-lane-client.js +0 -36
  63. package/dist/cjs/entry-points/src-pm-plugins-text-predictor.js +0 -66
  64. package/dist/es2019/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -2
  65. package/dist/es2019/entry-points/src-pm-plugins-slow-lane-client.js +0 -2
  66. package/dist/es2019/entry-points/src-pm-plugins-text-predictor.js +0 -2
  67. package/dist/esm/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -2
  68. package/dist/esm/entry-points/src-pm-plugins-slow-lane-client.js +0 -2
  69. package/dist/esm/entry-points/src-pm-plugins-text-predictor.js +0 -2
  70. package/dist/types/entry-points/src-pm-plugins-autocomplete-plugin.d.ts +0 -2
  71. package/dist/types/entry-points/src-pm-plugins-slow-lane-client.d.ts +0 -2
  72. package/dist/types/entry-points/src-pm-plugins-text-predictor.d.ts +0 -2
  73. package/scripts/gen_first_token_to_words.py +0 -170
  74. package/src/entry-points/src-pm-plugins-autocomplete-plugin.ts +0 -7
  75. package/src/entry-points/src-pm-plugins-slow-lane-client.ts +0 -13
  76. package/src/entry-points/src-pm-plugins-text-predictor.ts +0 -14
  77. package/src/pm-plugins/autocomplete-plugin/package.json +0 -8
  78. package/src/pm-plugins/slow-lane-client/package.json +0 -8
  79. package/src/pm-plugins/text-predictor/package.json +0 -8
@@ -14,7 +14,12 @@ import type { EditorView } from '@atlaskit/editor-prosemirror/view';
14
14
 
15
15
  import type { AutocompletePlugin } from '../autocompletePluginType';
16
16
 
17
- import { isAutocompleteDebugEnabled } from './debug-mode';
17
+ import {
18
+ CTC_STYLES,
19
+ ctcTag,
20
+ isAutocompleteDebugEnabled,
21
+ isAutocompleteDebugVerbose,
22
+ } from './debug-mode';
18
23
  import { createGhostTextDecorationSet } from './ghost-text-decoration';
19
24
  import {
20
25
  createLocalSlowLaneClient,
@@ -23,18 +28,29 @@ import {
23
28
  type LocalSlowLaneLoadSuccess,
24
29
  } from './local-slow-lane-client';
25
30
  import { loadGrammarDataAsync } from './scoring-pipeline';
26
- import { createSlowLaneClient, setDefaultSlowLaneClient, isWordBoundary } from './slow-lane-client';
31
+ import {
32
+ clearDefaultSlowLaneClient,
33
+ createSlowLaneClient,
34
+ getDefaultSlowLaneClientStatus,
35
+ isWordBoundary,
36
+ setDefaultSlowLaneClient,
37
+ type SlowLaneClientKind,
38
+ } from './slow-lane-client';
27
39
  import {
28
40
  predict,
41
+ type PredictionResult,
29
42
  loadDefaultVocabulary,
30
43
  loadVectorsAsync,
31
44
  incrementSessionFreq,
32
45
  ingestDocumentPage,
46
+ getLastPredictionDebug,
47
+ noteSuggestionAccepted,
33
48
  } from './text-predictor';
34
49
 
35
50
  export const autocompletePluginKey: PluginKey = new PluginKey('autocomplete');
36
51
 
37
- const DEBOUNCE_MS = 150;
52
+ const PREDICTION_COALESCE_MS = 0;
53
+ const GHOST_DECISION_BUDGET_MS = 100;
38
54
  const NETWORK_SLOW_LANE_DEBOUNCE_MS = 300;
39
55
  const LOCAL_SLOW_LANE_DEBOUNCE_MS = 100;
40
56
  const CONTEXT_REFRESH_THROTTLE_MS = 1000;
@@ -49,16 +65,70 @@ const MAX_INGESTED_CONTEXT_TEXTS = 50;
49
65
  // on every word boundary for the plugin's lifetime.
50
66
  const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
51
67
  const INLINE_LEAF_TEXT = ' ';
68
+ let slowLaneClientInstanceSequence = 0;
52
69
 
53
- // eslint-disable-next-line require-unicode-regexp
54
- const TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
55
70
  // eslint-disable-next-line require-unicode-regexp
56
71
  const WORD_BOUNDARY_CHARS_REGEX = /[\s.,;:!?]/;
72
+ const TRAILING_SURFACE_PREFIX_REGEX = /[\p{L}\p{N}_'-]+$/u;
73
+
74
+ type AsyncPredictionSignal = {
75
+ contextKey?: string;
76
+ familyKey?: string;
77
+ kind: 'boundary' | 'semantic' | 'surface';
78
+ latencyMs?: number;
79
+ };
80
+
81
+ type PredictionDecision = {
82
+ asyncSignals: number;
83
+ deadlineAt: number;
84
+ evaluations: number;
85
+ position: number;
86
+ revision: number;
87
+ startedAt: number;
88
+ textBefore: string;
89
+ };
57
90
 
58
91
  const hasDestroy = (
59
92
  client: ReturnType<typeof createSlowLaneClient> | LocalSlowLaneClient,
60
93
  ): client is LocalSlowLaneClient => 'destroy' in client && typeof client.destroy === 'function';
61
94
 
95
+ export interface CommittedSuggestion {
96
+ /** Wall-clock latency from the triggering editor update to the committed ghost. */
97
+ decisionLatencyMs: number;
98
+ /** Evidence tier that authorized this suggestion for display. */
99
+ evidenceTier: PredictionResult['evidenceTier'];
100
+ /** Ghost tail actually rendered and inserted. */
101
+ ghostText: string;
102
+ /**
103
+ * Mean per-token log-probability of the verified prefix, carried so an
104
+ * acceptance can be attributed to the evidence that authorized the display
105
+ * rather than to whatever the scorer holds by the time Tab arrives.
106
+ */
107
+ meanTokenLogProbability: number;
108
+ /**
109
+ * Whether a longer candidate in the same pool extended this surface, which
110
+ * hands it that pool's mass undivided under the chain rule and puts it near
111
+ * the top of the posterior range for a structural reason.
112
+ */
113
+ poolHeldExtension: boolean;
114
+ /** ProseMirror position at which the ghost is anchored. */
115
+ position: number;
116
+ /** Share of its shortlist's mass the model put on this surface. */
117
+ posterior: number;
118
+ /** Final confidence-v2 ranking score. */
119
+ rankScore: number;
120
+ /** Monotonic editor decision revision that owns this suggestion. */
121
+ revision: number;
122
+ /** How many scored candidates this surface's normaliser divided between. */
123
+ shortlistSize: number;
124
+ /** Full candidate surface, used by analytics and cooldown. */
125
+ surface: string;
126
+ /** Whether the surface is a single word, a bigram, or a phrase. */
127
+ termType: PredictionResult['termType'];
128
+ /** Difference between the selected rank score and its closest eligible rival. */
129
+ winnerMargin: number;
130
+ }
131
+
62
132
  export interface AutocompletePluginState {
63
133
  /** The decoration set containing the ghost text widget */
64
134
  decorationSet: DecorationSet;
@@ -66,14 +136,77 @@ export interface AutocompletePluginState {
66
136
  ghostPosition: number;
67
137
  /** The predicted ghost text to display */
68
138
  ghostText: string;
139
+ /** Single authoritative snapshot for rendering, insertion, analytics, and cooldown. */
140
+ suggestion: CommittedSuggestion | null;
69
141
  }
70
142
 
71
143
  const createInitialState = (): AutocompletePluginState => ({
72
144
  ghostText: '',
73
145
  ghostPosition: -1,
74
146
  decorationSet: DecorationSet.empty,
147
+ suggestion: null,
148
+ });
149
+
150
+ const clearGhostState = (pluginState: AutocompletePluginState): AutocompletePluginState => ({
151
+ ...pluginState,
152
+ ghostText: '',
153
+ ghostPosition: -1,
154
+ decorationSet: DecorationSet.empty,
155
+ suggestion: null,
75
156
  });
76
157
 
158
+ /**
159
+ * Keep a displayed ghost alive when the user types the character it already
160
+ * predicted.
161
+ *
162
+ * Clearing on every document change throws away a suggestion the keystroke just
163
+ * confirmed, and the replacement decision has to re-earn it from an empty state
164
+ * inside the budget — which it often misses, so the ghost blinks out mid-word.
165
+ * It is worse for multi-word surfaces: typing the space in `machine learning`
166
+ * empties the current word, and the display gate refuses to show anything again
167
+ * until three characters of `learning` exist.
168
+ *
169
+ * A confirming keystroke is not new information about the prediction, so the
170
+ * ghost simply advances by one character and the still-running decision is free
171
+ * to replace it with something better. Anything else — a different character,
172
+ * a paste, a deletion, an edit elsewhere — clears as before.
173
+ */
174
+ const advanceGhostThroughTypedCharacter = (
175
+ tr: ReadonlyTransaction,
176
+ pluginState: AutocompletePluginState,
177
+ ): AutocompletePluginState | null => {
178
+ const { ghostPosition, ghostText, suggestion } = pluginState;
179
+ if (ghostText.length < 2 || ghostPosition < 0 || !suggestion) {
180
+ // A one-character tail leaves nothing to show after advancing.
181
+ return null;
182
+ }
183
+
184
+ // Exactly one character longer, with the cursor sitting just past the ghost
185
+ // anchor: together these identify a single insertion made at the anchor
186
+ // rather than a paste or an edit elsewhere in the document.
187
+ if (tr.doc.content.size - tr.before.content.size !== 1) {
188
+ return null;
189
+ }
190
+ if (!tr.selection.empty || tr.selection.from !== ghostPosition + 1) {
191
+ return null;
192
+ }
193
+ if (tr.doc.textBetween(ghostPosition, ghostPosition + 1) !== ghostText[0]) {
194
+ return null;
195
+ }
196
+
197
+ const remaining = ghostText.slice(1);
198
+ const nextPosition = ghostPosition + 1;
199
+ return {
200
+ ...pluginState,
201
+ ghostText: remaining,
202
+ ghostPosition: nextPosition,
203
+ decorationSet: createGhostTextDecorationSet(tr.doc, nextPosition, remaining),
204
+ // `surface` stays the full candidate so cooldown and analytics still
205
+ // describe the suggestion that was originally committed.
206
+ suggestion: { ...suggestion, ghostText: remaining, position: nextPosition },
207
+ };
208
+ };
209
+
77
210
  /**
78
211
  * Extract text content before the cursor from the current document.
79
212
  * Returns the last ~200 characters for context.
@@ -111,6 +244,9 @@ const getTextBeforeCursor = (state: EditorState): string => {
111
244
  return fullText.slice(-maxChars);
112
245
  };
113
246
 
247
+ const getTrailingSurfacePrefixLength = (text: string): number =>
248
+ text.trimEnd().match(TRAILING_SURFACE_PREFIX_REGEX)?.[0].length ?? 0;
249
+
114
250
  /**
115
251
  * Set the autocomplete state via a transaction metadata.
116
252
  */
@@ -126,18 +262,42 @@ const setAutocompleteMeta = (
126
262
  */
127
263
  let lastShownGhostText = '';
128
264
 
129
- const showGhostText = (view: EditorView, text: string, position: number): void => {
265
+ const showGhostText = (
266
+ view: EditorView,
267
+ prediction: PredictionResult,
268
+ position: number,
269
+ revision: number,
270
+ decisionStartedAt: number,
271
+ ): CommittedSuggestion | null => {
130
272
  try {
131
273
  const { state, dispatch } = view;
132
- const decorationSet = createGhostTextDecorationSet(state, position, text);
274
+ const suggestion: CommittedSuggestion = {
275
+ decisionLatencyMs: performance.now() - decisionStartedAt,
276
+ evidenceTier: prediction.evidenceTier,
277
+ ghostText: prediction.text,
278
+ meanTokenLogProbability: prediction.meanTokenLogProbability,
279
+ poolHeldExtension: prediction.poolHeldExtension,
280
+ position,
281
+ posterior: prediction.posterior,
282
+ rankScore: prediction.rankScore,
283
+ revision,
284
+ shortlistSize: prediction.shortlistSize,
285
+ surface: prediction.surface,
286
+ termType: prediction.termType,
287
+ winnerMargin: prediction.winnerMargin,
288
+ };
289
+ const decorationSet = createGhostTextDecorationSet(state.doc, position, prediction.text);
133
290
  const tr = setAutocompleteMeta(state.tr, {
134
- ghostText: text,
291
+ ghostText: prediction.text,
135
292
  ghostPosition: position,
136
293
  decorationSet,
294
+ suggestion,
137
295
  });
138
296
  dispatch(tr);
297
+ return suggestion;
139
298
  } catch (error) {
140
299
  logException(error as Error, { location: 'editor-plugin-autocomplete/showGhostText' });
300
+ return null;
141
301
  }
142
302
  };
143
303
 
@@ -156,6 +316,7 @@ const clearGhostText = (state: EditorState, dispatch?: (tr: Transaction) => void
156
316
  ghostText: '',
157
317
  ghostPosition: -1,
158
318
  decorationSet: DecorationSet.empty,
319
+ suggestion: null,
159
320
  });
160
321
  dispatch(tr);
161
322
  } catch (error) {
@@ -169,42 +330,48 @@ const clearGhostText = (state: EditorState, dispatch?: (tr: Transaction) => void
169
330
  /**
170
331
  * Accept the current ghost text suggestion and insert it into the document.
171
332
  */
172
- const acceptGhostText = (state: EditorState, dispatch?: (tr: Transaction) => void): boolean => {
333
+ const acceptGhostText = (
334
+ state: EditorState,
335
+ dispatch?: (tr: Transaction) => void,
336
+ ): CommittedSuggestion | null => {
173
337
  const pluginState = autocompletePluginKey.getState(state) as AutocompletePluginState | undefined;
174
- if (!pluginState || !pluginState.ghostText) {
175
- return false;
338
+ if (!pluginState?.suggestion || !pluginState.ghostText) {
339
+ return null;
176
340
  }
177
341
 
178
342
  if (dispatch) {
179
343
  try {
180
- const { ghostText, ghostPosition } = pluginState;
344
+ const { ghostText, ghostPosition, suggestion } = pluginState;
181
345
  let tr = state.tr.insertText(ghostText, ghostPosition);
182
346
  tr = setAutocompleteMeta(tr, {
183
347
  ghostText: '',
184
348
  ghostPosition: -1,
185
349
  decorationSet: DecorationSet.empty,
350
+ suggestion: null,
186
351
  });
187
352
  dispatch(tr);
353
+ return suggestion;
188
354
  } catch (error) {
189
355
  logException(error as Error, { location: 'editor-plugin-autocomplete/acceptGhostText' });
190
- return false;
356
+ return null;
191
357
  }
192
358
  }
193
- return true;
359
+ return pluginState.suggestion;
194
360
  };
195
361
 
196
362
  const acceptGhostTextWithAnalytics = (
197
363
  state: EditorState,
198
364
  dispatch: ((tr: Transaction) => void) | undefined,
199
- onAccepted: (ghostText: string) => void,
365
+ onAccepted: (suggestion: CommittedSuggestion) => void,
200
366
  ): boolean => {
201
- const pluginState = autocompletePluginKey.getState(state) as AutocompletePluginState | undefined;
202
- const ghostText = pluginState?.ghostText ?? '';
203
- const accepted = acceptGhostText(state, dispatch);
204
- if (accepted) {
205
- onAccepted(ghostText);
367
+ const acceptedSuggestion = acceptGhostText(state, dispatch);
368
+ if (acceptedSuggestion) {
369
+ // Start the post-accept cooldown so the just-accepted unit isn't immediately
370
+ // re-offered (QI-2 echo, e.g. `end to end` → `end to end to end`).
371
+ noteSuggestionAccepted(acceptedSuggestion.surface);
372
+ onAccepted(acceptedSuggestion);
206
373
  }
207
- return accepted;
374
+ return acceptedSuggestion !== null;
208
375
  };
209
376
 
210
377
  /**
@@ -323,15 +490,6 @@ const getLeadingTextCharacter = (text?: string): string | undefined => {
323
490
  return String.fromCodePoint(firstCodePoint);
324
491
  };
325
492
 
326
- const getTypedLengthForPrediction = (textBefore: string): number => {
327
- if (isWordBoundary(textBefore)) {
328
- return 0;
329
- }
330
-
331
- const trailingToken = textBefore.match(TRAILING_NON_BOUNDARY_TOKEN_REGEX)?.[1] ?? '';
332
- return trailingToken.length;
333
- };
334
-
335
493
  const isEnglishLocale = (locale?: string): boolean => {
336
494
  if (!locale) {
337
495
  return false;
@@ -350,6 +508,7 @@ export const createAutocompletePlugin = (
350
508
  const isAutocompleteEnabled = isEnglishLocale(locale);
351
509
 
352
510
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
511
+ let decisionDeadlineTimer: ReturnType<typeof setTimeout> | null = null;
353
512
  let hasIngestedPage = false;
354
513
  let resolvedContext: AutocompleteContext | undefined;
355
514
  /**
@@ -359,6 +518,10 @@ export const createAutocompletePlugin = (
359
518
  */
360
519
  let currentView: EditorView | null = null;
361
520
  let unsubscribeFromContextUpdates: (() => void) | undefined;
521
+ // "Prime, don't ask-and-read": semantic, Tier-A, and Tier-B results land
522
+ // asynchronously, often with no following keystroke. Re-run prediction when
523
+ // they arrive so the caches are consumed immediately.
524
+ let reschedulePredictionOnSlowLaneResult: ((signal: AsyncPredictionSignal) => void) | null = null;
362
525
  /**
363
526
  * Set after accepting a suggestion so the next doc-change update
364
527
  * skips scheduling a new prediction for the just-inserted text.
@@ -372,8 +535,8 @@ export const createAutocompletePlugin = (
372
535
  * re-showing the same suggestion. Resets to null as soon as the text changes.
373
536
  */
374
537
  let dismissedContext: string | null = null;
375
- let lastSuggestionTypedLength = 0;
376
- let lastSuggestionLength = 0;
538
+ let decisionRevision = 0;
539
+ let activeDecision: PredictionDecision | undefined;
377
540
 
378
541
  /**
379
542
  * cold → no slow-lane vector received yet; frequency-only trie scoring
@@ -435,9 +598,9 @@ export const createAutocompletePlugin = (
435
598
  });
436
599
  };
437
600
 
438
- const fireSuggestionInsertedAnalytics = (ghostText: string): void => {
439
- const typedLength = lastSuggestionTypedLength;
440
- const suggestionLength = lastSuggestionLength || typedLength + ghostText.length;
601
+ const fireSuggestionInsertedAnalytics = (suggestion: CommittedSuggestion): void => {
602
+ const typedLength = Math.max(0, suggestion.surface.length - suggestion.ghostText.length);
603
+ const suggestionLength = suggestion.surface.length;
441
604
  const kssDelta = suggestionLength - typedLength;
442
605
 
443
606
  api?.analytics?.actions.fireAnalyticsEvent({
@@ -459,14 +622,44 @@ export const createAutocompletePlugin = (
459
622
  debounceMs: LOCAL_SLOW_LANE_DEBOUNCE_MS,
460
623
  onLoadSuccess: fireLocalModelLoadedAnalytics,
461
624
  onLoadError: fireLocalModelLoadFailedAnalytics,
625
+ // Re-run prediction for each independently scheduled model result.
626
+ onUpdate: () => reschedulePredictionOnSlowLaneResult?.({ kind: 'semantic' }),
627
+ onBoundaryLmUpdate: ({ contextKey, familyKey, latencyMs }) =>
628
+ reschedulePredictionOnSlowLaneResult?.({
629
+ contextKey,
630
+ familyKey,
631
+ kind: 'boundary',
632
+ latencyMs,
633
+ }),
634
+ onSurfaceScoreUpdate: ({ contextKey, familyKey, latencyMs }) =>
635
+ reschedulePredictionOnSlowLaneResult?.({
636
+ contextKey,
637
+ familyKey,
638
+ kind: 'surface',
639
+ latencyMs,
640
+ }),
462
641
  surface,
463
642
  })
464
643
  : createSlowLaneClient({
465
644
  baseUrl: '',
466
645
  debounceMs: NETWORK_SLOW_LANE_DEBOUNCE_MS,
646
+ // Re-run prediction when the BE semantic vector / LM logits land so
647
+ // warm-mode unigram ranking applies without waiting for a keystroke.
648
+ onUpdate: () => reschedulePredictionOnSlowLaneResult?.({ kind: 'semantic' }),
467
649
  surface,
468
650
  });
469
- setDefaultSlowLaneClient(slowLaneClient);
651
+ const slowLaneClientKind: SlowLaneClientKind = options?.useLocalModel ? 'localLlm' : 'server';
652
+ const slowLaneClientId = `${surface}:${slowLaneClientKind}:${++slowLaneClientInstanceSequence}`;
653
+ setDefaultSlowLaneClient(slowLaneClient, {
654
+ clientId: slowLaneClientId,
655
+ kind: slowLaneClientKind,
656
+ });
657
+ const registeredSlowLaneStatus = getDefaultSlowLaneClientStatus();
658
+ ctcTag(
659
+ 'init',
660
+ `slow lane registered · id=${slowLaneClientId} · selected=${slowLaneClientKind} · canonical=${registeredSlowLaneStatus.canonicalScoringSupported ? 'yes' : 'no'} · ready=${registeredSlowLaneStatus.localModelReady ?? 'n/a'}`,
661
+ slowLaneClientKind === 'localLlm' ? CTC_STYLES.good : CTC_STYLES.warn,
662
+ );
470
663
 
471
664
  let contextRequestInFlight = false;
472
665
  let lastContextRefreshAt = 0;
@@ -476,7 +669,67 @@ export const createAutocompletePlugin = (
476
669
  // Set when the plugin is torn down so in-flight getContext() resolutions don't
477
670
  // mutate the global text-predictor state after destruction.
478
671
  let destroyed = false;
479
- const ingestedContextTexts = new Set<string>();
672
+ // L1 ingestion cannot run before the vocabulary has loaded: `incrementSessionFreq`
673
+ // only touches trie nodes that already exist, so an empty trie silently rejects
674
+ // every word. Text that arrives first — which a page usually does, since the
675
+ // chat resolves it on focus in the same tick as the load starts — is held here
676
+ // and applied once the load has settled.
677
+ const pendingSessionContextTexts = new Set<string>();
678
+ const sessionIngestedContextTexts = new Set<string>();
679
+ let isVocabularyReady = false;
680
+ let vocabularyLoadPromise: Promise<void> | undefined;
681
+
682
+ const addBoundedContextText = (texts: Set<string>, text: string): void => {
683
+ texts.add(text);
684
+ // Evict oldest entries (Set preserves insertion order) to bound memory.
685
+ while (texts.size > MAX_INGESTED_CONTEXT_TEXTS) {
686
+ const oldest = texts.values().next().value;
687
+ if (oldest === undefined) {
688
+ break;
689
+ }
690
+ texts.delete(oldest);
691
+ }
692
+ };
693
+
694
+ const flushPendingSessionContext = (): void => {
695
+ if (!isVocabularyReady || destroyed) {
696
+ return;
697
+ }
698
+ for (const text of pendingSessionContextTexts) {
699
+ if (!sessionIngestedContextTexts.has(text)) {
700
+ ingestDocumentPage(text);
701
+ addBoundedContextText(sessionIngestedContextTexts, text);
702
+ }
703
+ pendingSessionContextTexts.delete(text);
704
+ }
705
+ };
706
+
707
+ const ensureVocabularyReady = (): Promise<void> => {
708
+ if (isVocabularyReady) {
709
+ flushPendingSessionContext();
710
+ return Promise.resolve();
711
+ }
712
+ vocabularyLoadPromise ??= loadDefaultVocabulary({
713
+ isLocalLLM: options?.useLocalModel ?? false,
714
+ surface,
715
+ })
716
+ .then(() => {
717
+ if (destroyed) {
718
+ return;
719
+ }
720
+ isVocabularyReady = true;
721
+ flushPendingSessionContext();
722
+ })
723
+ .catch((error) => {
724
+ // Do not consume the pending text on failure. A later focus retries the
725
+ // load and can still apply the original context exactly once.
726
+ vocabularyLoadPromise = undefined;
727
+ logException(error as Error, {
728
+ location: 'editor-plugin-autocomplete/loadDefaultVocabulary',
729
+ });
730
+ });
731
+ return vocabularyLoadPromise;
732
+ };
480
733
 
481
734
  const logContextResolved = (source: string, context?: AutocompleteContext): void => {
482
735
  if (!isAutocompleteDebugEnabled()) {
@@ -484,18 +737,13 @@ export const createAutocompletePlugin = (
484
737
  }
485
738
 
486
739
  // eslint-disable-next-line no-console
487
- console.log(
488
- '%c[Autocomplete] %cgetContext resolved',
489
- 'color: #00b8d9; font-weight: bold;',
490
- 'color: inherit;',
491
- {
492
- source,
493
- hasParentComment: !!context?.parentCommentContent,
494
- parentCommentPreview: context?.parentCommentContent?.slice(0, 80),
495
- siblingCount: context?.siblingCommentsContents?.length ?? 0,
496
- hasFullPage: !!context?.fullPageContent,
497
- },
498
- );
740
+ console.log('%c[CTC:signal]%c getContext resolved', CTC_STYLES.brand, CTC_STYLES.body, {
741
+ source,
742
+ hasParentComment: !!context?.parentCommentContent,
743
+ parentCommentPreview: context?.parentCommentContent?.slice(0, 80),
744
+ siblingCount: context?.siblingCommentsContents?.length ?? 0,
745
+ hasFullPage: !!context?.fullPageContent,
746
+ });
499
747
  };
500
748
 
501
749
  const applyContext = (context: AutocompleteContext): void => {
@@ -510,20 +758,11 @@ export const createAutocompletePlugin = (
510
758
  resolvedContext = { ...resolvedContext, ...definedContext };
511
759
 
512
760
  const ingestContextText = (text?: string): void => {
513
- if (!text || ingestedContextTexts.has(text)) {
761
+ if (!text || sessionIngestedContextTexts.has(text)) {
514
762
  return;
515
763
  }
516
764
 
517
- ingestedContextTexts.add(text);
518
- // Evict oldest entries (Set preserves insertion order) to bound memory.
519
- while (ingestedContextTexts.size > MAX_INGESTED_CONTEXT_TEXTS) {
520
- const oldest = ingestedContextTexts.values().next().value;
521
- if (oldest === undefined) {
522
- break;
523
- }
524
- ingestedContextTexts.delete(oldest);
525
- }
526
- ingestDocumentPage(text);
765
+ addBoundedContextText(pendingSessionContextTexts, text);
527
766
  };
528
767
 
529
768
  ingestContextText(context.fullPageContent);
@@ -531,6 +770,7 @@ export const createAutocompletePlugin = (
531
770
  for (const siblingCommentContent of context.siblingCommentsContents ?? []) {
532
771
  ingestContextText(siblingCommentContent);
533
772
  }
773
+ flushPendingSessionContext();
534
774
 
535
775
  // Context arrived after word boundaries may already have fired. Re-send
536
776
  // slow-lane context immediately so the next inference includes the thread.
@@ -593,77 +833,273 @@ export const createAutocompletePlugin = (
593
833
  return true;
594
834
  };
595
835
 
596
- /**
597
- * Schedule a prediction after a short debounce.
598
- * Tier 1 predictions are synchronous (<0.1ms) but we still debounce
599
- * to avoid unnecessary work on rapid keystrokes.
600
- */
601
- const schedulePrediction = (view: EditorView): void => {
836
+ const clearDecisionTimers = (): void => {
602
837
  if (debounceTimer) {
603
838
  clearTimeout(debounceTimer);
839
+ debounceTimer = null;
604
840
  }
841
+ if (decisionDeadlineTimer) {
842
+ clearTimeout(decisionDeadlineTimer);
843
+ decisionDeadlineTimer = null;
844
+ }
845
+ };
605
846
 
606
- debounceTimer = setTimeout(() => {
607
- try {
608
- const { state } = view;
609
- const { selection } = state;
847
+ const cancelActiveDecision = (): void => {
848
+ clearDecisionTimers();
849
+ activeDecision = undefined;
850
+ };
610
851
 
611
- if (!selection.empty) {
612
- return;
613
- }
852
+ const isDecisionSnapshotCurrent = (view: EditorView, decision: PredictionDecision): boolean => {
853
+ const { state } = view;
854
+ return (
855
+ state.selection.empty &&
856
+ state.selection.from === decision.position &&
857
+ getTextBeforeCursor(state) === decision.textBefore
858
+ );
859
+ };
614
860
 
615
- // Suppress suggestions when the cursor is mid-word — only offer
616
- // completions when the cursor is at the trailing edge of a token.
617
- // nodeAfter correctly handles inline atoms (mentions, emojis) where
618
- // parentOffset and textContent indices diverge.
619
- const nodeAfter = selection.$from.nodeAfter;
620
- const charAfterCursor = nodeAfter?.isText
621
- ? getLeadingTextCharacter(nodeAfter.text)
622
- : undefined;
623
- // Only suppress for Unicode letters, digits, and underscore — hyphens,
624
- // apostrophes, and similar punctuation are valid left-edge boundaries
625
- // and should not block suggestions (e.g. cursor before '-' in compound-word).
626
- if (charAfterCursor && isMidWordCharacter(charAfterCursor)) {
627
- return;
628
- }
861
+ const isDecisionCurrent = (view: EditorView, decision: PredictionDecision): boolean =>
862
+ activeDecision?.revision === decision.revision && isDecisionSnapshotCurrent(view, decision);
629
863
 
630
- const textBefore = getTextBeforeCursor(state);
864
+ const expireActiveDecision = (decision: PredictionDecision): void => {
865
+ if (activeDecision?.revision !== decision.revision) {
866
+ return;
867
+ }
631
868
 
632
- // Suppress re-showing the same suggestion the user just dismissed.
633
- // Once the text context changes (user types or deletes), this clears automatically.
634
- if (textBefore === dismissedContext) {
635
- return;
636
- }
637
- dismissedContext = null;
869
+ cancelActiveDecision();
870
+ };
638
871
 
639
- if (textBefore.trim().length < 3) {
640
- return;
641
- }
872
+ const evaluateActiveDecision = (view: EditorView, revision: number): void => {
873
+ const decision = activeDecision;
874
+ if (!decision || decision.revision !== revision || !isDecisionCurrent(view, decision)) {
875
+ return;
876
+ }
642
877
 
643
- const prediction = predict(textBefore);
644
-
645
- if (prediction && prediction.length > 0) {
646
- const typedLength = getTypedLengthForPrediction(textBefore);
647
- lastSuggestionTypedLength = typedLength;
648
- lastSuggestionLength = typedLength + prediction.length;
649
-
650
- showGhostText(view, prediction, selection.from);
651
- if (prediction !== lastShownGhostText) {
652
- lastShownGhostText = prediction;
653
- api?.analytics?.actions.fireAnalyticsEvent({
654
- action: ACTION.SUGGESTION_VIEWED,
655
- actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
656
- eventType: EVENT_TYPE.TRACK,
657
- attributes: { completionSource: getCompletionSource(), surface },
658
- });
659
- }
878
+ if (performance.now() > decision.deadlineAt) {
879
+ expireActiveDecision(decision);
880
+ return;
881
+ }
882
+ decision.evaluations++;
883
+
884
+ try {
885
+ const { state } = view;
886
+ const { selection } = state;
887
+
888
+ // Suppress suggestions when the cursor is mid-word — only offer
889
+ // completions when the cursor is at the trailing edge of a token.
890
+ const nodeAfter = selection.$from.nodeAfter;
891
+ const charAfterCursor = nodeAfter?.isText
892
+ ? getLeadingTextCharacter(nodeAfter.text)
893
+ : undefined;
894
+ if (charAfterCursor && isMidWordCharacter(charAfterCursor)) {
895
+ cancelActiveDecision();
896
+ return;
897
+ }
898
+
899
+ if (decision.textBefore === dismissedContext) {
900
+ cancelActiveDecision();
901
+ return;
902
+ }
903
+ dismissedContext = null;
904
+
905
+ // Two characters are enough to prefetch the canonical shortlist. The
906
+ // predictor itself withholds display until the three-character gate.
907
+ if (decision.textBefore.trim().length < 2) {
908
+ cancelActiveDecision();
909
+ return;
910
+ }
911
+
912
+ const prediction = predict(decision.textBefore);
913
+ if (!prediction || prediction.text.length === 0) {
914
+ // Remain in `collecting` until an async signal arrives or the hard
915
+ // deadline expires. We never display a provisional fallback.
916
+ return;
917
+ }
918
+
919
+ const readyAtMs = performance.now() - decision.startedAt;
920
+ if (readyAtMs > GHOST_DECISION_BUDGET_MS) {
921
+ cancelActiveDecision();
922
+ if (isAutocompleteDebugEnabled()) {
923
+ // eslint-disable-next-line no-console
924
+ console.log(
925
+ '%c[CTC:budget]%c fully gated after render deadline (render suppressed)',
926
+ CTC_STYLES.section,
927
+ CTC_STYLES.body,
928
+ {
929
+ revision: decision.revision,
930
+ readyAtMs,
931
+ surface: prediction.surface,
932
+ },
933
+ );
660
934
  }
661
- } catch (error) {
662
- logException(error as Error, {
663
- location: 'editor-plugin-autocomplete/schedulePrediction',
935
+ return;
936
+ }
937
+
938
+ const suggestion = showGhostText(
939
+ view,
940
+ prediction,
941
+ decision.position,
942
+ decision.revision,
943
+ decision.startedAt,
944
+ );
945
+ if (!suggestion) {
946
+ cancelActiveDecision();
947
+ return;
948
+ }
949
+
950
+ cancelActiveDecision();
951
+ if (isAutocompleteDebugEnabled()) {
952
+ // eslint-disable-next-line no-console
953
+ console.log(
954
+ '%c[CTC:decision]%c committed immutable ghost',
955
+ CTC_STYLES.brand,
956
+ CTC_STYLES.body,
957
+ {
958
+ revision: suggestion.revision,
959
+ surface: suggestion.surface,
960
+ ghostText: suggestion.ghostText,
961
+ evidenceTier: suggestion.evidenceTier,
962
+ rankScore: suggestion.rankScore,
963
+ winnerMargin: suggestion.winnerMargin,
964
+ decisionLatencyMs: suggestion.decisionLatencyMs,
965
+ budgetMs: GHOST_DECISION_BUDGET_MS,
966
+ typedPrefixLength: getTrailingSurfacePrefixLength(decision.textBefore),
967
+ evaluations: decision.evaluations,
968
+ asyncSignals: decision.asyncSignals,
969
+ readyWithinBudget: suggestion.decisionLatencyMs <= GHOST_DECISION_BUDGET_MS,
970
+ },
971
+ );
972
+ }
973
+ if (suggestion.ghostText !== lastShownGhostText) {
974
+ lastShownGhostText = suggestion.ghostText;
975
+ api?.analytics?.actions.fireAnalyticsEvent({
976
+ action: ACTION.SUGGESTION_VIEWED,
977
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
978
+ eventType: EVENT_TYPE.TRACK,
979
+ attributes: { completionSource: getCompletionSource(), surface },
664
980
  });
665
981
  }
666
- }, DEBOUNCE_MS);
982
+ } catch (error) {
983
+ cancelActiveDecision();
984
+ logException(error as Error, {
985
+ location: 'editor-plugin-autocomplete/evaluateActiveDecision',
986
+ });
987
+ }
988
+ };
989
+
990
+ const beginPredictionDecision = (view: EditorView): void => {
991
+ cancelActiveDecision();
992
+ const { state } = view;
993
+ if (!state.selection.empty) {
994
+ return;
995
+ }
996
+
997
+ const startedAt = performance.now();
998
+ const textBefore = getTextBeforeCursor(state);
999
+ const revision = ++decisionRevision;
1000
+ activeDecision = {
1001
+ asyncSignals: 0,
1002
+ deadlineAt: startedAt + GHOST_DECISION_BUDGET_MS,
1003
+ evaluations: 0,
1004
+ position: state.selection.from,
1005
+ revision,
1006
+ startedAt,
1007
+ textBefore,
1008
+ };
1009
+ if (isAutocompleteDebugEnabled()) {
1010
+ // eslint-disable-next-line no-console
1011
+ console.log('%c[CTC:decision]%c collecting', CTC_STYLES.brand, CTC_STYLES.body, {
1012
+ revision,
1013
+ budgetMs: GHOST_DECISION_BUDGET_MS,
1014
+ coalesceMs: PREDICTION_COALESCE_MS,
1015
+ typedPrefixLength: getTrailingSurfacePrefixLength(activeDecision.textBefore),
1016
+ textBefore: activeDecision.textBefore.slice(-80),
1017
+ });
1018
+ }
1019
+
1020
+ debounceTimer = setTimeout(() => {
1021
+ debounceTimer = null;
1022
+ evaluateActiveDecision(view, revision);
1023
+ }, PREDICTION_COALESCE_MS);
1024
+ decisionDeadlineTimer = setTimeout(() => {
1025
+ decisionDeadlineTimer = null;
1026
+ if (activeDecision?.revision === revision) {
1027
+ const expiredDecision = activeDecision;
1028
+ if (isAutocompleteDebugEnabled()) {
1029
+ const elapsedMs = performance.now() - expiredDecision.startedAt;
1030
+ const predictionDebug = getLastPredictionDebug();
1031
+ const matchingPredictionDebug =
1032
+ predictionDebug?.textBefore === expiredDecision.textBefore ? predictionDebug : null;
1033
+ const abstentionPrefix = '— abstain: ';
1034
+ const predictorDecision = matchingPredictionDebug?.decision;
1035
+ const predictorReason = predictorDecision?.startsWith(abstentionPrefix)
1036
+ ? predictorDecision.slice(abstentionPrefix.length)
1037
+ : predictorDecision;
1038
+ const decisionMessage =
1039
+ predictorReason && !matchingPredictionDebug?.awaitingAsyncEvidence
1040
+ ? `abstain: ${predictorReason}`
1041
+ : `abstain: ${GHOST_DECISION_BUDGET_MS}ms decision budget expired${predictorReason ? ` · ${predictorReason}` : ''}`;
1042
+ // eslint-disable-next-line no-console
1043
+ console.log(`%c[CTC:decision]%c ${decisionMessage}`, CTC_STYLES.brand, CTC_STYLES.body, {
1044
+ revision,
1045
+ budgetMs: GHOST_DECISION_BUDGET_MS,
1046
+ elapsedMs,
1047
+ typedPrefixLength: getTrailingSurfacePrefixLength(expiredDecision.textBefore),
1048
+ evaluations: expiredDecision.evaluations,
1049
+ asyncSignals: expiredDecision.asyncSignals,
1050
+ textBefore: expiredDecision.textBefore.slice(-80),
1051
+ predictorDecision: predictorReason ?? null,
1052
+ });
1053
+ }
1054
+ expireActiveDecision(expiredDecision);
1055
+ }
1056
+ }, GHOST_DECISION_BUDGET_MS);
1057
+ };
1058
+
1059
+ // Async semantic/canonical results may complete the active decision, but they
1060
+ // never create a new decision or replace an already committed ghost.
1061
+ reschedulePredictionOnSlowLaneResult = (signal) => {
1062
+ if (destroyed || !currentView) {
1063
+ return;
1064
+ }
1065
+ if (!activeDecision) {
1066
+ return;
1067
+ }
1068
+ const revision = activeDecision.revision;
1069
+ const elapsedMs = performance.now() - activeDecision.startedAt;
1070
+ const remainingMs = activeDecision.deadlineAt - performance.now();
1071
+ if (remainingMs < 0) {
1072
+ const expiredDecision = activeDecision;
1073
+ expireActiveDecision(expiredDecision);
1074
+ return;
1075
+ }
1076
+
1077
+ activeDecision.asyncSignals++;
1078
+ if (isAutocompleteDebugVerbose()) {
1079
+ // eslint-disable-next-line no-console
1080
+ console.log(
1081
+ '%c[CTC:readiness]%c async evidence signal',
1082
+ CTC_STYLES.section,
1083
+ CTC_STYLES.body,
1084
+ {
1085
+ ...signal,
1086
+ revision,
1087
+ elapsedMs,
1088
+ remainingMs: Math.max(0, remainingMs),
1089
+ typedPrefixLength: getTrailingSurfacePrefixLength(activeDecision.textBefore),
1090
+ withinBudget: remainingMs >= 0,
1091
+ },
1092
+ );
1093
+ }
1094
+ if (debounceTimer) {
1095
+ clearTimeout(debounceTimer);
1096
+ }
1097
+ debounceTimer = setTimeout(() => {
1098
+ debounceTimer = null;
1099
+ if (currentView) {
1100
+ evaluateActiveDecision(currentView, revision);
1101
+ }
1102
+ }, 0);
667
1103
  };
668
1104
 
669
1105
  const maybeUpdateSessionFrequency = (view: EditorView, prevState: EditorState): void => {
@@ -712,23 +1148,14 @@ export const createAutocompletePlugin = (
712
1148
  return { ...pluginState, ...meta };
713
1149
  }
714
1150
 
715
- // A new prediction is scheduled from view.update.
1151
+ // A new prediction is scheduled from view.update either way; the
1152
+ // surviving ghost only covers the gap until it commits.
716
1153
  if (tr.docChanged) {
717
- return {
718
- ...pluginState,
719
- ghostText: '',
720
- ghostPosition: -1,
721
- decorationSet: DecorationSet.empty,
722
- };
1154
+ return advanceGhostThroughTypedCharacter(tr, pluginState) ?? clearGhostState(pluginState);
723
1155
  }
724
1156
 
725
1157
  if (tr.selectionSet && pluginState.ghostText) {
726
- return {
727
- ...pluginState,
728
- ghostText: '',
729
- ghostPosition: -1,
730
- decorationSet: DecorationSet.empty,
731
- };
1158
+ return clearGhostState(pluginState);
732
1159
  }
733
1160
 
734
1161
  return pluginState;
@@ -745,22 +1172,25 @@ export const createAutocompletePlugin = (
745
1172
 
746
1173
  handleKeyDown: keydownHandler({
747
1174
  Tab: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
748
- return acceptGhostTextWithAnalytics(state, dispatch, (ghostText) => {
1175
+ return acceptGhostTextWithAnalytics(state, dispatch, (suggestion) => {
749
1176
  justAccepted = true;
750
1177
  lastShownGhostText = '';
751
- fireSuggestionInsertedAnalytics(ghostText);
1178
+ cancelActiveDecision();
1179
+ fireSuggestionInsertedAnalytics(suggestion);
752
1180
  });
753
1181
  },
754
1182
  ArrowRight: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
755
- return acceptGhostTextWithAnalytics(state, dispatch, (ghostText) => {
1183
+ return acceptGhostTextWithAnalytics(state, dispatch, (suggestion) => {
756
1184
  justAccepted = true;
757
1185
  lastShownGhostText = '';
758
- fireSuggestionInsertedAnalytics(ghostText);
1186
+ cancelActiveDecision();
1187
+ fireSuggestionInsertedAnalytics(suggestion);
759
1188
  });
760
1189
  },
761
1190
  Escape: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
762
1191
  const didClear = clearGhostText(state, dispatch);
763
1192
  if (didClear) {
1193
+ cancelActiveDecision();
764
1194
  dismissedContext = getTextBeforeCursor(state);
765
1195
  fireSuggestionDismissedAnalytics('escape');
766
1196
  }
@@ -779,6 +1209,7 @@ export const createAutocompletePlugin = (
779
1209
  | undefined;
780
1210
  if (pluginState?.ghostText) {
781
1211
  clearGhostText(view.state, view.dispatch);
1212
+ cancelActiveDecision();
782
1213
  fireSuggestionDismissedAnalytics('blur');
783
1214
  }
784
1215
  return false;
@@ -791,6 +1222,7 @@ export const createAutocompletePlugin = (
791
1222
  if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
792
1223
  const didClear = clearGhostText(view.state, view.dispatch);
793
1224
  if (didClear) {
1225
+ cancelActiveDecision();
794
1226
  dismissedContext = getTextBeforeCursor(view.state);
795
1227
  lastShownGhostText = '';
796
1228
  fireSuggestionDismissedAnalytics('click');
@@ -800,10 +1232,11 @@ export const createAutocompletePlugin = (
800
1232
 
801
1233
  event.preventDefault();
802
1234
 
803
- const accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, (ghostText) => {
1235
+ const accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, (suggestion) => {
804
1236
  justAccepted = true;
805
1237
  lastShownGhostText = '';
806
- fireSuggestionInsertedAnalytics(ghostText);
1238
+ cancelActiveDecision();
1239
+ fireSuggestionInsertedAnalytics(suggestion);
807
1240
  });
808
1241
 
809
1242
  if (accepted) {
@@ -817,14 +1250,7 @@ export const createAutocompletePlugin = (
817
1250
  return false;
818
1251
  }
819
1252
 
820
- loadDefaultVocabulary({
821
- isLocalLLM: options?.useLocalModel ?? false,
822
- surface,
823
- }).catch((error) => {
824
- logException(error as Error, {
825
- location: 'editor-plugin-autocomplete/loadDefaultVocabulary',
826
- });
827
- });
1253
+ void ensureVocabularyReady();
828
1254
  loadVectorsAsync({
829
1255
  isLocalLLM: options?.useLocalModel ?? false,
830
1256
  surface,
@@ -881,9 +1307,7 @@ export const createAutocompletePlugin = (
881
1307
  // the dismissedContext guard and abort until the user types again.
882
1308
  dismissedContext = getTextBeforeCursor(view.state);
883
1309
 
884
- if (debounceTimer) {
885
- clearTimeout(debounceTimer);
886
- }
1310
+ cancelActiveDecision();
887
1311
  return;
888
1312
  }
889
1313
 
@@ -914,7 +1338,9 @@ export const createAutocompletePlugin = (
914
1338
  }
915
1339
  }
916
1340
 
917
- schedulePrediction(view);
1341
+ beginPredictionDecision(view);
1342
+ } else if (!prevState.selection.eq(view.state.selection)) {
1343
+ cancelActiveDecision();
918
1344
  }
919
1345
  },
920
1346
  destroy: () => {
@@ -922,14 +1348,18 @@ export const createAutocompletePlugin = (
922
1348
  currentView = null;
923
1349
  unsubscribeFromContextUpdates?.();
924
1350
  unsubscribeFromContextUpdates = undefined;
925
- if (debounceTimer) {
926
- clearTimeout(debounceTimer);
927
- }
1351
+ cancelActiveDecision();
1352
+ const releasedDefaultClient = clearDefaultSlowLaneClient(slowLaneClient);
1353
+ ctcTag(
1354
+ 'init',
1355
+ `slow lane teardown · id=${slowLaneClientId} · default=${releasedDefaultClient ? 'released' : 'retained newer owner'}`,
1356
+ releasedDefaultClient ? CTC_STYLES.dim : CTC_STYLES.warn,
1357
+ );
928
1358
  if (hasDestroy(slowLaneClient)) {
929
1359
  slowLaneClient.destroy();
930
1360
  }
931
- ingestedContextTexts.clear();
932
- setDefaultSlowLaneClient(null);
1361
+ pendingSessionContextTexts.clear();
1362
+ sessionIngestedContextTexts.clear();
933
1363
  },
934
1364
  };
935
1365
  },