@atlaskit/editor-plugin-autocomplete 8.0.0 → 9.0.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 +80 -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 +416 -129
  6. package/dist/cjs/pm-plugins/canonical-lm-scoring.js +371 -0
  7. package/dist/cjs/pm-plugins/debug-mode.js +90 -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 +1951 -273
  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 +425 -130
  17. package/dist/es2019/pm-plugins/canonical-lm-scoring.js +270 -0
  18. package/dist/es2019/pm-plugins/debug-mode.js +87 -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 +1603 -199
  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 +419 -132
  28. package/dist/esm/pm-plugins/canonical-lm-scoring.js +364 -0
  29. package/dist/esm/pm-plugins/debug-mode.js +89 -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 +1953 -277
  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 +36 -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 +120 -2
  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 +517 -133
  51. package/src/pm-plugins/canonical-lm-scoring.ts +412 -0
  52. package/src/pm-plugins/debug-mode.ts +109 -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 +2006 -255
  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;
@@ -484,18 +677,13 @@ export const createAutocompletePlugin = (
484
677
  }
485
678
 
486
679
  // 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
- );
680
+ console.log('%c[CTC:signal]%c getContext resolved', CTC_STYLES.brand, CTC_STYLES.body, {
681
+ source,
682
+ hasParentComment: !!context?.parentCommentContent,
683
+ parentCommentPreview: context?.parentCommentContent?.slice(0, 80),
684
+ siblingCount: context?.siblingCommentsContents?.length ?? 0,
685
+ hasFullPage: !!context?.fullPageContent,
686
+ });
499
687
  };
500
688
 
501
689
  const applyContext = (context: AutocompleteContext): void => {
@@ -593,77 +781,273 @@ export const createAutocompletePlugin = (
593
781
  return true;
594
782
  };
595
783
 
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 => {
784
+ const clearDecisionTimers = (): void => {
602
785
  if (debounceTimer) {
603
786
  clearTimeout(debounceTimer);
787
+ debounceTimer = null;
788
+ }
789
+ if (decisionDeadlineTimer) {
790
+ clearTimeout(decisionDeadlineTimer);
791
+ decisionDeadlineTimer = null;
604
792
  }
793
+ };
605
794
 
606
- debounceTimer = setTimeout(() => {
607
- try {
608
- const { state } = view;
609
- const { selection } = state;
795
+ const cancelActiveDecision = (): void => {
796
+ clearDecisionTimers();
797
+ activeDecision = undefined;
798
+ };
610
799
 
611
- if (!selection.empty) {
612
- return;
613
- }
800
+ const isDecisionSnapshotCurrent = (view: EditorView, decision: PredictionDecision): boolean => {
801
+ const { state } = view;
802
+ return (
803
+ state.selection.empty &&
804
+ state.selection.from === decision.position &&
805
+ getTextBeforeCursor(state) === decision.textBefore
806
+ );
807
+ };
614
808
 
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
- }
809
+ const isDecisionCurrent = (view: EditorView, decision: PredictionDecision): boolean =>
810
+ activeDecision?.revision === decision.revision && isDecisionSnapshotCurrent(view, decision);
629
811
 
630
- const textBefore = getTextBeforeCursor(state);
812
+ const expireActiveDecision = (decision: PredictionDecision): void => {
813
+ if (activeDecision?.revision !== decision.revision) {
814
+ return;
815
+ }
631
816
 
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;
817
+ cancelActiveDecision();
818
+ };
638
819
 
639
- if (textBefore.trim().length < 3) {
640
- return;
641
- }
820
+ const evaluateActiveDecision = (view: EditorView, revision: number): void => {
821
+ const decision = activeDecision;
822
+ if (!decision || decision.revision !== revision || !isDecisionCurrent(view, decision)) {
823
+ return;
824
+ }
642
825
 
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
- }
826
+ if (performance.now() > decision.deadlineAt) {
827
+ expireActiveDecision(decision);
828
+ return;
829
+ }
830
+ decision.evaluations++;
831
+
832
+ try {
833
+ const { state } = view;
834
+ const { selection } = state;
835
+
836
+ // Suppress suggestions when the cursor is mid-word — only offer
837
+ // completions when the cursor is at the trailing edge of a token.
838
+ const nodeAfter = selection.$from.nodeAfter;
839
+ const charAfterCursor = nodeAfter?.isText
840
+ ? getLeadingTextCharacter(nodeAfter.text)
841
+ : undefined;
842
+ if (charAfterCursor && isMidWordCharacter(charAfterCursor)) {
843
+ cancelActiveDecision();
844
+ return;
845
+ }
846
+
847
+ if (decision.textBefore === dismissedContext) {
848
+ cancelActiveDecision();
849
+ return;
850
+ }
851
+ dismissedContext = null;
852
+
853
+ // Two characters are enough to prefetch the canonical shortlist. The
854
+ // predictor itself withholds display until the three-character gate.
855
+ if (decision.textBefore.trim().length < 2) {
856
+ cancelActiveDecision();
857
+ return;
858
+ }
859
+
860
+ const prediction = predict(decision.textBefore);
861
+ if (!prediction || prediction.text.length === 0) {
862
+ // Remain in `collecting` until an async signal arrives or the hard
863
+ // deadline expires. We never display a provisional fallback.
864
+ return;
865
+ }
866
+
867
+ const readyAtMs = performance.now() - decision.startedAt;
868
+ if (readyAtMs > GHOST_DECISION_BUDGET_MS) {
869
+ cancelActiveDecision();
870
+ if (isAutocompleteDebugEnabled()) {
871
+ // eslint-disable-next-line no-console
872
+ console.log(
873
+ '%c[CTC:budget]%c fully gated after render deadline (render suppressed)',
874
+ CTC_STYLES.section,
875
+ CTC_STYLES.body,
876
+ {
877
+ revision: decision.revision,
878
+ readyAtMs,
879
+ surface: prediction.surface,
880
+ },
881
+ );
660
882
  }
661
- } catch (error) {
662
- logException(error as Error, {
663
- location: 'editor-plugin-autocomplete/schedulePrediction',
883
+ return;
884
+ }
885
+
886
+ const suggestion = showGhostText(
887
+ view,
888
+ prediction,
889
+ decision.position,
890
+ decision.revision,
891
+ decision.startedAt,
892
+ );
893
+ if (!suggestion) {
894
+ cancelActiveDecision();
895
+ return;
896
+ }
897
+
898
+ cancelActiveDecision();
899
+ if (isAutocompleteDebugEnabled()) {
900
+ // eslint-disable-next-line no-console
901
+ console.log(
902
+ '%c[CTC:decision]%c committed immutable ghost',
903
+ CTC_STYLES.brand,
904
+ CTC_STYLES.body,
905
+ {
906
+ revision: suggestion.revision,
907
+ surface: suggestion.surface,
908
+ ghostText: suggestion.ghostText,
909
+ evidenceTier: suggestion.evidenceTier,
910
+ rankScore: suggestion.rankScore,
911
+ winnerMargin: suggestion.winnerMargin,
912
+ decisionLatencyMs: suggestion.decisionLatencyMs,
913
+ budgetMs: GHOST_DECISION_BUDGET_MS,
914
+ typedPrefixLength: getTrailingSurfacePrefixLength(decision.textBefore),
915
+ evaluations: decision.evaluations,
916
+ asyncSignals: decision.asyncSignals,
917
+ readyWithinBudget: suggestion.decisionLatencyMs <= GHOST_DECISION_BUDGET_MS,
918
+ },
919
+ );
920
+ }
921
+ if (suggestion.ghostText !== lastShownGhostText) {
922
+ lastShownGhostText = suggestion.ghostText;
923
+ api?.analytics?.actions.fireAnalyticsEvent({
924
+ action: ACTION.SUGGESTION_VIEWED,
925
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
926
+ eventType: EVENT_TYPE.TRACK,
927
+ attributes: { completionSource: getCompletionSource(), surface },
664
928
  });
665
929
  }
666
- }, DEBOUNCE_MS);
930
+ } catch (error) {
931
+ cancelActiveDecision();
932
+ logException(error as Error, {
933
+ location: 'editor-plugin-autocomplete/evaluateActiveDecision',
934
+ });
935
+ }
936
+ };
937
+
938
+ const beginPredictionDecision = (view: EditorView): void => {
939
+ cancelActiveDecision();
940
+ const { state } = view;
941
+ if (!state.selection.empty) {
942
+ return;
943
+ }
944
+
945
+ const startedAt = performance.now();
946
+ const textBefore = getTextBeforeCursor(state);
947
+ const revision = ++decisionRevision;
948
+ activeDecision = {
949
+ asyncSignals: 0,
950
+ deadlineAt: startedAt + GHOST_DECISION_BUDGET_MS,
951
+ evaluations: 0,
952
+ position: state.selection.from,
953
+ revision,
954
+ startedAt,
955
+ textBefore,
956
+ };
957
+ if (isAutocompleteDebugEnabled()) {
958
+ // eslint-disable-next-line no-console
959
+ console.log('%c[CTC:decision]%c collecting', CTC_STYLES.brand, CTC_STYLES.body, {
960
+ revision,
961
+ budgetMs: GHOST_DECISION_BUDGET_MS,
962
+ coalesceMs: PREDICTION_COALESCE_MS,
963
+ typedPrefixLength: getTrailingSurfacePrefixLength(activeDecision.textBefore),
964
+ textBefore: activeDecision.textBefore.slice(-80),
965
+ });
966
+ }
967
+
968
+ debounceTimer = setTimeout(() => {
969
+ debounceTimer = null;
970
+ evaluateActiveDecision(view, revision);
971
+ }, PREDICTION_COALESCE_MS);
972
+ decisionDeadlineTimer = setTimeout(() => {
973
+ decisionDeadlineTimer = null;
974
+ if (activeDecision?.revision === revision) {
975
+ const expiredDecision = activeDecision;
976
+ if (isAutocompleteDebugEnabled()) {
977
+ const elapsedMs = performance.now() - expiredDecision.startedAt;
978
+ const predictionDebug = getLastPredictionDebug();
979
+ const matchingPredictionDebug =
980
+ predictionDebug?.textBefore === expiredDecision.textBefore ? predictionDebug : null;
981
+ const abstentionPrefix = '— abstain: ';
982
+ const predictorDecision = matchingPredictionDebug?.decision;
983
+ const predictorReason = predictorDecision?.startsWith(abstentionPrefix)
984
+ ? predictorDecision.slice(abstentionPrefix.length)
985
+ : predictorDecision;
986
+ const decisionMessage =
987
+ predictorReason && !matchingPredictionDebug?.awaitingAsyncEvidence
988
+ ? `abstain: ${predictorReason}`
989
+ : `abstain: ${GHOST_DECISION_BUDGET_MS}ms decision budget expired${predictorReason ? ` · ${predictorReason}` : ''}`;
990
+ // eslint-disable-next-line no-console
991
+ console.log(`%c[CTC:decision]%c ${decisionMessage}`, CTC_STYLES.brand, CTC_STYLES.body, {
992
+ revision,
993
+ budgetMs: GHOST_DECISION_BUDGET_MS,
994
+ elapsedMs,
995
+ typedPrefixLength: getTrailingSurfacePrefixLength(expiredDecision.textBefore),
996
+ evaluations: expiredDecision.evaluations,
997
+ asyncSignals: expiredDecision.asyncSignals,
998
+ textBefore: expiredDecision.textBefore.slice(-80),
999
+ predictorDecision: predictorReason ?? null,
1000
+ });
1001
+ }
1002
+ expireActiveDecision(expiredDecision);
1003
+ }
1004
+ }, GHOST_DECISION_BUDGET_MS);
1005
+ };
1006
+
1007
+ // Async semantic/canonical results may complete the active decision, but they
1008
+ // never create a new decision or replace an already committed ghost.
1009
+ reschedulePredictionOnSlowLaneResult = (signal) => {
1010
+ if (destroyed || !currentView) {
1011
+ return;
1012
+ }
1013
+ if (!activeDecision) {
1014
+ return;
1015
+ }
1016
+ const revision = activeDecision.revision;
1017
+ const elapsedMs = performance.now() - activeDecision.startedAt;
1018
+ const remainingMs = activeDecision.deadlineAt - performance.now();
1019
+ if (remainingMs < 0) {
1020
+ const expiredDecision = activeDecision;
1021
+ expireActiveDecision(expiredDecision);
1022
+ return;
1023
+ }
1024
+
1025
+ activeDecision.asyncSignals++;
1026
+ if (isAutocompleteDebugVerbose()) {
1027
+ // eslint-disable-next-line no-console
1028
+ console.log(
1029
+ '%c[CTC:readiness]%c async evidence signal',
1030
+ CTC_STYLES.section,
1031
+ CTC_STYLES.body,
1032
+ {
1033
+ ...signal,
1034
+ revision,
1035
+ elapsedMs,
1036
+ remainingMs: Math.max(0, remainingMs),
1037
+ typedPrefixLength: getTrailingSurfacePrefixLength(activeDecision.textBefore),
1038
+ withinBudget: remainingMs >= 0,
1039
+ },
1040
+ );
1041
+ }
1042
+ if (debounceTimer) {
1043
+ clearTimeout(debounceTimer);
1044
+ }
1045
+ debounceTimer = setTimeout(() => {
1046
+ debounceTimer = null;
1047
+ if (currentView) {
1048
+ evaluateActiveDecision(currentView, revision);
1049
+ }
1050
+ }, 0);
667
1051
  };
668
1052
 
669
1053
  const maybeUpdateSessionFrequency = (view: EditorView, prevState: EditorState): void => {
@@ -712,23 +1096,14 @@ export const createAutocompletePlugin = (
712
1096
  return { ...pluginState, ...meta };
713
1097
  }
714
1098
 
715
- // A new prediction is scheduled from view.update.
1099
+ // A new prediction is scheduled from view.update either way; the
1100
+ // surviving ghost only covers the gap until it commits.
716
1101
  if (tr.docChanged) {
717
- return {
718
- ...pluginState,
719
- ghostText: '',
720
- ghostPosition: -1,
721
- decorationSet: DecorationSet.empty,
722
- };
1102
+ return advanceGhostThroughTypedCharacter(tr, pluginState) ?? clearGhostState(pluginState);
723
1103
  }
724
1104
 
725
1105
  if (tr.selectionSet && pluginState.ghostText) {
726
- return {
727
- ...pluginState,
728
- ghostText: '',
729
- ghostPosition: -1,
730
- decorationSet: DecorationSet.empty,
731
- };
1106
+ return clearGhostState(pluginState);
732
1107
  }
733
1108
 
734
1109
  return pluginState;
@@ -745,22 +1120,25 @@ export const createAutocompletePlugin = (
745
1120
 
746
1121
  handleKeyDown: keydownHandler({
747
1122
  Tab: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
748
- return acceptGhostTextWithAnalytics(state, dispatch, (ghostText) => {
1123
+ return acceptGhostTextWithAnalytics(state, dispatch, (suggestion) => {
749
1124
  justAccepted = true;
750
1125
  lastShownGhostText = '';
751
- fireSuggestionInsertedAnalytics(ghostText);
1126
+ cancelActiveDecision();
1127
+ fireSuggestionInsertedAnalytics(suggestion);
752
1128
  });
753
1129
  },
754
1130
  ArrowRight: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
755
- return acceptGhostTextWithAnalytics(state, dispatch, (ghostText) => {
1131
+ return acceptGhostTextWithAnalytics(state, dispatch, (suggestion) => {
756
1132
  justAccepted = true;
757
1133
  lastShownGhostText = '';
758
- fireSuggestionInsertedAnalytics(ghostText);
1134
+ cancelActiveDecision();
1135
+ fireSuggestionInsertedAnalytics(suggestion);
759
1136
  });
760
1137
  },
761
1138
  Escape: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
762
1139
  const didClear = clearGhostText(state, dispatch);
763
1140
  if (didClear) {
1141
+ cancelActiveDecision();
764
1142
  dismissedContext = getTextBeforeCursor(state);
765
1143
  fireSuggestionDismissedAnalytics('escape');
766
1144
  }
@@ -779,6 +1157,7 @@ export const createAutocompletePlugin = (
779
1157
  | undefined;
780
1158
  if (pluginState?.ghostText) {
781
1159
  clearGhostText(view.state, view.dispatch);
1160
+ cancelActiveDecision();
782
1161
  fireSuggestionDismissedAnalytics('blur');
783
1162
  }
784
1163
  return false;
@@ -791,6 +1170,7 @@ export const createAutocompletePlugin = (
791
1170
  if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
792
1171
  const didClear = clearGhostText(view.state, view.dispatch);
793
1172
  if (didClear) {
1173
+ cancelActiveDecision();
794
1174
  dismissedContext = getTextBeforeCursor(view.state);
795
1175
  lastShownGhostText = '';
796
1176
  fireSuggestionDismissedAnalytics('click');
@@ -800,10 +1180,11 @@ export const createAutocompletePlugin = (
800
1180
 
801
1181
  event.preventDefault();
802
1182
 
803
- const accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, (ghostText) => {
1183
+ const accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, (suggestion) => {
804
1184
  justAccepted = true;
805
1185
  lastShownGhostText = '';
806
- fireSuggestionInsertedAnalytics(ghostText);
1186
+ cancelActiveDecision();
1187
+ fireSuggestionInsertedAnalytics(suggestion);
807
1188
  });
808
1189
 
809
1190
  if (accepted) {
@@ -881,9 +1262,7 @@ export const createAutocompletePlugin = (
881
1262
  // the dismissedContext guard and abort until the user types again.
882
1263
  dismissedContext = getTextBeforeCursor(view.state);
883
1264
 
884
- if (debounceTimer) {
885
- clearTimeout(debounceTimer);
886
- }
1265
+ cancelActiveDecision();
887
1266
  return;
888
1267
  }
889
1268
 
@@ -914,7 +1293,9 @@ export const createAutocompletePlugin = (
914
1293
  }
915
1294
  }
916
1295
 
917
- schedulePrediction(view);
1296
+ beginPredictionDecision(view);
1297
+ } else if (!prevState.selection.eq(view.state.selection)) {
1298
+ cancelActiveDecision();
918
1299
  }
919
1300
  },
920
1301
  destroy: () => {
@@ -922,14 +1303,17 @@ export const createAutocompletePlugin = (
922
1303
  currentView = null;
923
1304
  unsubscribeFromContextUpdates?.();
924
1305
  unsubscribeFromContextUpdates = undefined;
925
- if (debounceTimer) {
926
- clearTimeout(debounceTimer);
927
- }
1306
+ cancelActiveDecision();
1307
+ const releasedDefaultClient = clearDefaultSlowLaneClient(slowLaneClient);
1308
+ ctcTag(
1309
+ 'init',
1310
+ `slow lane teardown · id=${slowLaneClientId} · default=${releasedDefaultClient ? 'released' : 'retained newer owner'}`,
1311
+ releasedDefaultClient ? CTC_STYLES.dim : CTC_STYLES.warn,
1312
+ );
928
1313
  if (hasDestroy(slowLaneClient)) {
929
1314
  slowLaneClient.destroy();
930
1315
  }
931
1316
  ingestedContextTexts.clear();
932
- setDefaultSlowLaneClient(null);
933
1317
  },
934
1318
  };
935
1319
  },