@atlaskit/editor-plugin-autocomplete 9.0.0 → 9.2.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.
@@ -1,8 +1,10 @@
1
1
  import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
2
+ import type { CompletionSource } from '@atlaskit/editor-common/analytics';
2
3
  import { logException } from '@atlaskit/editor-common/monitoring';
3
4
  import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
4
5
  import type { ExtractInjectionAPI } from '@atlaskit/editor-common/types';
5
6
  import { keydownHandler } from '@atlaskit/editor-prosemirror/keymap';
7
+ import type { Node as PMNode } from '@atlaskit/editor-prosemirror/model';
6
8
  import { PluginKey } from '@atlaskit/editor-prosemirror/state';
7
9
  import type {
8
10
  EditorState,
@@ -21,6 +23,9 @@ import {
21
23
  isAutocompleteDebugVerbose,
22
24
  } from './debug-mode';
23
25
  import { createGhostTextDecorationSet } from './ghost-text-decoration';
26
+ // Type-only, so the harvester chunk is still reached exclusively through the
27
+ // dynamic import in `ensureInlineCodeHarvester`.
28
+ import type { HarvestMatch } from './inline-code-harvester';
24
29
  import {
25
30
  createLocalSlowLaneClient,
26
31
  type LocalSlowLaneClient,
@@ -38,13 +43,17 @@ import {
38
43
  } from './slow-lane-client';
39
44
  import {
40
45
  predict,
46
+ type CtcAbstainReason,
41
47
  type PredictionResult,
42
48
  loadDefaultVocabulary,
43
49
  loadVectorsAsync,
44
50
  incrementSessionFreq,
45
51
  ingestDocumentPage,
46
52
  getLastPredictionDebug,
53
+ getLastPredictionOutcome,
54
+ isSurfaceInAcceptCooldown,
47
55
  noteSuggestionAccepted,
56
+ resetSessionBoosts,
48
57
  } from './text-predictor';
49
58
 
50
59
  export const autocompletePluginKey: PluginKey = new PluginKey('autocomplete');
@@ -58,6 +67,33 @@ const CONTEXT_REFRESH_THROTTLE_MS = 1000;
58
67
  // version of the (potentially hundreds-of-KB) page content for the plugin's
59
68
  // lifetime. Eviction is FIFO; a re-ingest of an evicted text is harmless.
60
69
  const MAX_INGESTED_CONTEXT_TEXTS = 50;
70
+ // Re-prime callback of every mounted editor. The harvested set and the L1
71
+ // boosts are shared by all of them, and more than one is mounted routinely — a
72
+ // comment box alongside its replies, or a comment box alongside the chat input.
73
+ // Membership doubles as the mount count, so a teardown only clears the shared
74
+ // stores once the last editor has gone.
75
+ //
76
+ // Decision: one pot, shared across hosts. Terms harvested in a comment box can
77
+ // be offered in the chat input and the other way round. Both are content from
78
+ // the page the reader is looking at, so the mixing is between things they can
79
+ // already see; partitioning the boosts would mean a per-host overlay on every
80
+ // trie node, which the scoring path reads on every keystroke.
81
+ const mountedEditors = new Set<() => void>();
82
+ // The scope that shared learning belongs to, as last reported by a host that
83
+ // scopes its context. Held here rather than per editor because the data it
84
+ // guards is shared and some hosts replace one editor with another across a
85
+ // navigation: an instance-local key would read as unset on exactly the change it
86
+ // exists to catch, while the previous scope's terms carried on in these globals.
87
+ // Undefined until a scoping host says otherwise. An editor that sends no key
88
+ // still shares the pot being emptied, so it is re-primed from the context it
89
+ // already holds rather than left with learning it can no longer see.
90
+ let contextScopeKey: string | undefined;
91
+ type InlineCodeHarvesterModule = typeof import('./inline-code-harvester');
92
+ // Set by whichever editor first finished importing the harvester chunk. The
93
+ // reset paths go through this rather than their own copy, because the editor
94
+ // that has to clear the set is not always one that imported it — the set is one
95
+ // per module, and nothing can be in it unless some editor got this far.
96
+ let loadedInlineCodeHarvester: InlineCodeHarvesterModule | null = null;
61
97
  // Caps how many times the word-boundary path will retry getContext() while the
62
98
  // parent comment is still missing. Combined with the 1s throttle this gives a
63
99
  // ~5s window to cover a still-loading comment thread, then stops permanently so
@@ -117,6 +153,17 @@ export interface CommittedSuggestion {
117
153
  posterior: number;
118
154
  /** Final confidence-v2 ranking score. */
119
155
  rankScore: number;
156
+ /**
157
+ * Characters of already-typed prefix the insertion overwrites, for surfaces
158
+ * that carry their own casing.
159
+ *
160
+ * The scored path appends its tail and leaves what the user typed alone,
161
+ * which is right for prose. A harvested identifier is matched
162
+ * case-insensitively but is only correct in the casing it was written in, so
163
+ * accepting `ml-s` against `ML-Studio` has to replace rather than append.
164
+ * Absent on every other path.
165
+ */
166
+ replacesTypedPrefixLength?: number;
120
167
  /** Monotonic editor decision revision that owns this suggestion. */
121
168
  revision: number;
122
169
  /** How many scored candidates this surface's normaliser divided between. */
@@ -202,8 +249,17 @@ const advanceGhostThroughTypedCharacter = (
202
249
  ghostPosition: nextPosition,
203
250
  decorationSet: createGhostTextDecorationSet(tr.doc, nextPosition, remaining),
204
251
  // `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 },
252
+ // describe the suggestion that was originally committed. The replaced
253
+ // prefix grows with the keystroke: it is the run the insertion overwrites,
254
+ // and the user has just typed one more character of it.
255
+ suggestion: {
256
+ ...suggestion,
257
+ ghostText: remaining,
258
+ position: nextPosition,
259
+ ...(suggestion.replacesTypedPrefixLength === undefined
260
+ ? {}
261
+ : { replacesTypedPrefixLength: suggestion.replacesTypedPrefixLength + 1 }),
262
+ },
207
263
  };
208
264
  };
209
265
 
@@ -244,8 +300,11 @@ const getTextBeforeCursor = (state: EditorState): string => {
244
300
  return fullText.slice(-maxChars);
245
301
  };
246
302
 
303
+ const getTrailingSurfaceToken = (text: string): string =>
304
+ text.trimEnd().match(TRAILING_SURFACE_PREFIX_REGEX)?.[0] ?? '';
305
+
247
306
  const getTrailingSurfacePrefixLength = (text: string): number =>
248
- text.trimEnd().match(TRAILING_SURFACE_PREFIX_REGEX)?.[0].length ?? 0;
307
+ getTrailingSurfaceToken(text).length;
249
308
 
250
309
  /**
251
310
  * Set the autocomplete state via a transaction metadata.
@@ -268,10 +327,12 @@ const showGhostText = (
268
327
  position: number,
269
328
  revision: number,
270
329
  decisionStartedAt: number,
330
+ replacesTypedPrefixLength?: number,
271
331
  ): CommittedSuggestion | null => {
272
332
  try {
273
333
  const { state, dispatch } = view;
274
334
  const suggestion: CommittedSuggestion = {
335
+ ...(replacesTypedPrefixLength === undefined ? {} : { replacesTypedPrefixLength }),
275
336
  decisionLatencyMs: performance.now() - decisionStartedAt,
276
337
  evidenceTier: prediction.evidenceTier,
277
338
  ghostText: prediction.text,
@@ -342,7 +403,30 @@ const acceptGhostText = (
342
403
  if (dispatch) {
343
404
  try {
344
405
  const { ghostText, ghostPosition, suggestion } = pluginState;
345
- let tr = state.tr.insertText(ghostText, ghostPosition);
406
+ // Replacing back over the typed prefix rewrites it in the surface's own
407
+ // casing; every other path appends and leaves the prefix untouched.
408
+ const replaceFrom = Math.max(
409
+ 0,
410
+ ghostPosition - Math.min(suggestion.replacesTypedPrefixLength ?? 0, ghostPosition),
411
+ );
412
+ let tr = state.tr;
413
+ if (replaceFrom === ghostPosition) {
414
+ tr = tr.insertText(ghostText, ghostPosition);
415
+ } else {
416
+ tr = tr.insertText(suggestion.surface, replaceFrom, ghostPosition);
417
+ // Only the harvested path replaces, and what authorized it was the
418
+ // surface being marked as code somewhere in the session. Inserting it
419
+ // as plain text loses that, so the identifier the user accepted reads
420
+ // as prose while the same identifier they typed by hand does not.
421
+ const codeMark = state.schema.marks.code;
422
+ if (codeMark) {
423
+ tr = tr.addMark(replaceFrom, replaceFrom + suggestion.surface.length, codeMark.create());
424
+ // The backtick input rule never ran, so nothing else will close this
425
+ // mark. Without dropping it from the stored set the next character
426
+ // the user types continues the code span.
427
+ tr = tr.removeStoredMark(codeMark);
428
+ }
429
+ }
346
430
  tr = setAutocompleteMeta(tr, {
347
431
  ghostText: '',
348
432
  ghostPosition: -1,
@@ -381,6 +465,21 @@ const acceptGhostTextWithAnalytics = (
381
465
  * priority boost.
382
466
  */
383
467
  export interface AutocompleteContext {
468
+ /**
469
+ * Identifies what this context belongs to, for hosts whose editor outlives
470
+ * the thing it is writing about — a chat input that stays mounted across
471
+ * conversations and pages, say.
472
+ *
473
+ * When it changes, everything learned for the previous scope is dropped:
474
+ * both the L1 boosts and the harvested inline-code set are claims about what
475
+ * is being discussed, and neither transfers. The context reported alongside
476
+ * the new key is then ingested as if it were the first, so anything still
477
+ * current — an ongoing transcript, for instance — is primed again.
478
+ *
479
+ * Omit it and nothing resets; the editor learns for its own lifetime, which
480
+ * is right for a host mounted per comment or per page.
481
+ */
482
+ contextScopeKey?: string;
384
483
  /** Full page content as a string (e.g. markdown). */
385
484
  fullPageContent?: string;
386
485
  /** The currently selected text on the page, if any. */
@@ -397,6 +496,17 @@ export interface AutocompletePluginOptions {
397
496
  * word-frequency boosting. Called lazily so the preset can remain synchronous.
398
497
  */
399
498
  getContext?: () => Promise<AutocompleteContext | undefined>;
499
+ /**
500
+ * Opt in to suggesting inline-code identifiers seen in this session
501
+ * (`ml-studio` and friends), which no vocabulary holds and the scored path
502
+ * therefore cannot reach.
503
+ *
504
+ * Off by default and passed only by hosts whose own experiment enrolment
505
+ * covers it, so a surface that shares this plugin under a different gate is
506
+ * unaffected until it opts in too. The harvester is a separate chunk and is
507
+ * not requested at all while this is false.
508
+ */
509
+ harvestInlineCode?: boolean;
400
510
  /**
401
511
  * User locale used to determine whether autocomplete should run.
402
512
  * Defaults to browser locale when omitted.
@@ -490,6 +600,69 @@ const getLeadingTextCharacter = (text?: string): string | undefined => {
490
600
  return String.fromCodePoint(firstCodePoint);
491
601
  };
492
602
 
603
+ /**
604
+ * The one verdict a harvested surface may speak on.
605
+ *
606
+ * `no-candidate` is the only abstention that means no vocabulary reaches the
607
+ * prefix at all. Every other one — a margin the model cannot clear, a rival
608
+ * still being read — describes known words in contention, where a session
609
+ * surface with no score behind it would be overruling the ranker rather than
610
+ * filling a gap it left.
611
+ */
612
+ const HARVEST_ELIGIBLE_ABSTAIN_REASON: CtcAbstainReason = 'no-candidate';
613
+
614
+ /**
615
+ * Whether the scored path has finished with this exact prefix and found nothing.
616
+ *
617
+ * The 100ms deadline expiring is not the same answer: it means the ranker was
618
+ * still working, and displaying then would race a suggestion that is about to
619
+ * arrive. So the harvest path reads the recorded verdict rather than the clock.
620
+ */
621
+ const scoredPathFinishedEmpty = (textBefore: string): boolean => {
622
+ const outcome = getLastPredictionOutcome();
623
+ return (
624
+ outcome !== null &&
625
+ outcome.textBefore === textBefore &&
626
+ !outcome.awaitingAsyncEvidence &&
627
+ outcome.abstainReason === HARVEST_ELIGIBLE_ABSTAIN_REASON
628
+ );
629
+ };
630
+
631
+ /**
632
+ * Whether the surface is already sitting immediately before the typed prefix.
633
+ *
634
+ * Completing `ml-s` to `ml-studio` right after `ml-studio` produces the echo the
635
+ * scored path's repetition guard exists to stop, and this path does not go
636
+ * through arbitration to inherit it.
637
+ */
638
+ const repeatsPrecedingText = (match: HarvestMatch, textBefore: string): boolean => {
639
+ const trimmed = textBefore.trimEnd();
640
+ const preceding = trimmed.slice(0, trimmed.length - match.typedPrefixLength).trimEnd();
641
+ return preceding.toLowerCase().endsWith(match.surface.toLowerCase());
642
+ };
643
+
644
+ /**
645
+ * Dress a harvested match as a prediction so it commits through the same path as
646
+ * a scored one.
647
+ *
648
+ * The scoring fields are zeroed rather than invented: there is no posterior, no
649
+ * margin and no shortlist behind this surface, and `session-harvest` on the
650
+ * evidence tier is what says so wherever the snapshot is read.
651
+ */
652
+ const buildHarvestPrediction = (match: HarvestMatch): PredictionResult => ({
653
+ evidenceDepth: { totalChars: 0, totalTokens: 0, verifiedChars: 0, verifiedTokens: 0 },
654
+ evidenceTier: 'session-harvest',
655
+ meanTokenLogProbability: 0,
656
+ poolHeldExtension: false,
657
+ posterior: 0,
658
+ rankScore: 0,
659
+ shortlistSize: 0,
660
+ surface: match.surface,
661
+ termType: 'word',
662
+ text: match.ghostText,
663
+ winnerMargin: 0,
664
+ });
665
+
493
666
  const isEnglishLocale = (locale?: string): boolean => {
494
667
  if (!locale) {
495
668
  return false;
@@ -506,6 +679,7 @@ export const createAutocompletePlugin = (
506
679
  const locale =
507
680
  options?.locale ?? (typeof navigator !== 'undefined' ? navigator.language : undefined);
508
681
  const isAutocompleteEnabled = isEnglishLocale(locale);
682
+ const isInlineCodeHarvestEnabled = isAutocompleteEnabled && options?.harvestInlineCode === true;
509
683
 
510
684
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
511
685
  let decisionDeadlineTimer: ReturnType<typeof setTimeout> | null = null;
@@ -543,19 +717,33 @@ export const createAutocompletePlugin = (
543
717
  * server → server slow-lane API returned the context vector
544
718
  * localLlm → on-device WebGPU/MLC model returned the context vector
545
719
  */
546
- const getCompletionSource = (): 'cold' | 'server' | 'localLlm' => {
720
+ const getCompletionSource = (): CompletionSource => {
547
721
  if (!slowLaneClient.getContextVector()) {
548
722
  return 'cold';
549
723
  }
550
724
  return options?.useLocalModel ? 'localLlm' : 'server';
551
725
  };
552
726
 
553
- const fireSuggestionDismissedAnalytics = (reason: 'escape' | 'blur' | 'click'): void => {
727
+ /**
728
+ * Which path produced a given suggestion.
729
+ *
730
+ * A harvested surface is reported as `harvest` rather than by the slow-lane
731
+ * state it happened to be shown under, so its views and acceptances stay
732
+ * separable from the scored path's and cannot quietly move the headline
733
+ * acceptance rate.
734
+ */
735
+ const completionSourceFor = (suggestion: CommittedSuggestion | null): CompletionSource =>
736
+ suggestion?.evidenceTier === 'session-harvest' ? 'harvest' : getCompletionSource();
737
+
738
+ const fireSuggestionDismissedAnalytics = (
739
+ reason: 'escape' | 'blur' | 'click',
740
+ suggestion: CommittedSuggestion | null,
741
+ ): void => {
554
742
  api?.analytics?.actions.fireAnalyticsEvent({
555
743
  action: ACTION.SUGGESTION_DISMISSED,
556
744
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
557
745
  eventType: EVENT_TYPE.TRACK,
558
- attributes: { completionSource: getCompletionSource(), reason, surface },
746
+ attributes: { completionSource: completionSourceFor(suggestion), reason, surface },
559
747
  });
560
748
  };
561
749
 
@@ -608,7 +796,7 @@ export const createAutocompletePlugin = (
608
796
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
609
797
  eventType: EVENT_TYPE.TRACK,
610
798
  attributes: {
611
- completionSource: getCompletionSource(),
799
+ completionSource: completionSourceFor(suggestion),
612
800
  suggestionLength,
613
801
  typedLength,
614
802
  kssDelta,
@@ -662,6 +850,10 @@ export const createAutocompletePlugin = (
662
850
  );
663
851
 
664
852
  let contextRequestInFlight = false;
853
+ // A read the host asked for while another was open, kept so it can be made
854
+ // once that one settles. One slot, not a queue: a burst of notifications only
855
+ // ever means "read again", and the last of them describes the current state.
856
+ let queuedContextRefreshSource: string | undefined;
665
857
  let lastContextRefreshAt = 0;
666
858
  // Bounds the word-boundary retry loop so it terminates even when the editor is
667
859
  // not in a comment thread (parentCommentContent never resolves).
@@ -669,7 +861,154 @@ export const createAutocompletePlugin = (
669
861
  // Set when the plugin is torn down so in-flight getContext() resolutions don't
670
862
  // mutate the global text-predictor state after destruction.
671
863
  let destroyed = false;
672
- const ingestedContextTexts = new Set<string>();
864
+ // L1 ingestion cannot run before the vocabulary has loaded: `incrementSessionFreq`
865
+ // only touches trie nodes that already exist, so an empty trie silently rejects
866
+ // every word. Text that arrives first — which a page usually does, since the
867
+ // chat resolves it on focus in the same tick as the load starts — is held here
868
+ // and applied once the load has settled.
869
+ const pendingSessionContextTexts = new Set<string>();
870
+ const sessionIngestedContextTexts = new Set<string>();
871
+ let isVocabularyReady = false;
872
+ let vocabularyLoadPromise: Promise<void> | undefined;
873
+
874
+ // Context text waiting on the harvester chunk, the vocabulary, or both. The
875
+ // harvester drops surfaces the vocabulary already holds, so intake before the
876
+ // load has settled would admit ordinary words no one needs completed.
877
+ const pendingHarvestTexts = new Set<string>();
878
+ // Separate from `sessionIngestedContextTexts` because the two consumers settle
879
+ // at different times: reply occurrences accumulate, so text fed twice would
880
+ // count twice.
881
+ const harvestedContextTexts = new Set<string>();
882
+ let inlineCodeHarvester: InlineCodeHarvesterModule | null = null;
883
+ let inlineCodeHarvesterPromise: Promise<InlineCodeHarvesterModule | null> | undefined;
884
+
885
+ const addBoundedContextText = (texts: Set<string>, text: string): void => {
886
+ texts.add(text);
887
+ // Evict oldest entries (Set preserves insertion order) to bound memory.
888
+ while (texts.size > MAX_INGESTED_CONTEXT_TEXTS) {
889
+ const oldest = texts.values().next().value;
890
+ if (oldest === undefined) {
891
+ break;
892
+ }
893
+ texts.delete(oldest);
894
+ }
895
+ };
896
+
897
+ const flushPendingSessionContext = (): void => {
898
+ if (!isVocabularyReady || destroyed) {
899
+ return;
900
+ }
901
+ for (const text of pendingSessionContextTexts) {
902
+ if (!sessionIngestedContextTexts.has(text)) {
903
+ ingestDocumentPage(text);
904
+ addBoundedContextText(sessionIngestedContextTexts, text);
905
+ }
906
+ pendingSessionContextTexts.delete(text);
907
+ }
908
+ };
909
+
910
+ const flushPendingHarvestTexts = (): void => {
911
+ const harvester = inlineCodeHarvester;
912
+ if (!harvester || !isVocabularyReady || destroyed || pendingHarvestTexts.size === 0) {
913
+ return;
914
+ }
915
+ for (const text of pendingHarvestTexts) {
916
+ if (!harvestedContextTexts.has(text)) {
917
+ harvester.harvestInlineCodeFromText(text);
918
+ addBoundedContextText(harvestedContextTexts, text);
919
+ }
920
+ pendingHarvestTexts.delete(text);
921
+ }
922
+ harvester.logInlineCodeHarvest('context');
923
+ };
924
+
925
+ /**
926
+ * Load the harvester chunk, once, and only for a host that asked for it.
927
+ *
928
+ * Kept off the critical path in the same way the vocabulary and vector loads
929
+ * are: nothing is requested until the editor is focused, so a session that
930
+ * never types in the chat pays nothing for the feature.
931
+ */
932
+ const ensureInlineCodeHarvester = (): Promise<InlineCodeHarvesterModule | null> => {
933
+ if (!isInlineCodeHarvestEnabled || destroyed) {
934
+ return Promise.resolve(null);
935
+ }
936
+ inlineCodeHarvesterPromise ??= import(
937
+ /* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-inline-code-harvester" */
938
+ './inline-code-harvester'
939
+ )
940
+ .then((module) => {
941
+ if (destroyed) {
942
+ return null;
943
+ }
944
+ inlineCodeHarvester = module;
945
+ loadedInlineCodeHarvester = module;
946
+ flushPendingHarvestTexts();
947
+ return module;
948
+ })
949
+ .catch((error) => {
950
+ // Clear the promise so a later focus can retry; the queued text is
951
+ // still held and is fed by whichever attempt succeeds.
952
+ inlineCodeHarvesterPromise = undefined;
953
+ logException(error as Error, {
954
+ location: 'editor-plugin-autocomplete/loadInlineCodeHarvester',
955
+ });
956
+ return null;
957
+ });
958
+ return inlineCodeHarvesterPromise;
959
+ };
960
+
961
+ /**
962
+ * Re-read the document's code-marked spans.
963
+ *
964
+ * The backtick input rule consumes both delimiters on the closing tick, so a
965
+ * finished span in the live document is only findable through its mark — and
966
+ * only by walking, since a mark carries no notification.
967
+ */
968
+ const harvestDocument = (doc: PMNode, trigger: string): void => {
969
+ if (!isInlineCodeHarvestEnabled || destroyed) {
970
+ return;
971
+ }
972
+ const harvester = inlineCodeHarvester;
973
+ if (!harvester) {
974
+ void ensureInlineCodeHarvester();
975
+ return;
976
+ }
977
+ if (!isVocabularyReady) {
978
+ return;
979
+ }
980
+ harvester.harvestInlineCodeFromDoc(doc);
981
+ harvester.logInlineCodeHarvest(trigger);
982
+ };
983
+
984
+ const ensureVocabularyReady = (): Promise<void> => {
985
+ if (isVocabularyReady) {
986
+ flushPendingSessionContext();
987
+ flushPendingHarvestTexts();
988
+ return Promise.resolve();
989
+ }
990
+ vocabularyLoadPromise ??= loadDefaultVocabulary({
991
+ isLocalLLM: options?.useLocalModel ?? false,
992
+ surface,
993
+ })
994
+ .then(() => {
995
+ if (destroyed) {
996
+ return;
997
+ }
998
+ isVocabularyReady = true;
999
+ flushPendingSessionContext();
1000
+ flushPendingHarvestTexts();
1001
+ })
1002
+ .catch((error) => {
1003
+ // Do not consume the pending text on failure. A later focus retries the
1004
+ // load and can still apply the original context exactly once.
1005
+ vocabularyLoadPromise = undefined;
1006
+ logException(error as Error, {
1007
+ location: 'editor-plugin-autocomplete/loadDefaultVocabulary',
1008
+ });
1009
+ });
1010
+ return vocabularyLoadPromise;
1011
+ };
673
1012
 
674
1013
  const logContextResolved = (source: string, context?: AutocompleteContext): void => {
675
1014
  if (!isAutocompleteDebugEnabled()) {
@@ -679,6 +1018,7 @@ export const createAutocompletePlugin = (
679
1018
  // eslint-disable-next-line no-console
680
1019
  console.log('%c[CTC:signal]%c getContext resolved', CTC_STYLES.brand, CTC_STYLES.body, {
681
1020
  source,
1021
+ scope: context?.contextScopeKey,
682
1022
  hasParentComment: !!context?.parentCommentContent,
683
1023
  parentCommentPreview: context?.parentCommentContent?.slice(0, 80),
684
1024
  siblingCount: context?.siblingCommentsContents?.length ?? 0,
@@ -686,7 +1026,109 @@ export const createAutocompletePlugin = (
686
1026
  });
687
1027
  };
688
1028
 
1029
+ const ingestContextText = (text?: string): void => {
1030
+ if (!text) {
1031
+ return;
1032
+ }
1033
+
1034
+ if (!sessionIngestedContextTexts.has(text)) {
1035
+ addBoundedContextText(pendingSessionContextTexts, text);
1036
+ }
1037
+ // Queued unconditionally rather than only once the chunk is present:
1038
+ // context usually resolves in the same tick the load starts, and text
1039
+ // dropped for arriving early is a reply that can never be harvested.
1040
+ if (isInlineCodeHarvestEnabled && !harvestedContextTexts.has(text)) {
1041
+ addBoundedContextText(pendingHarvestTexts, text);
1042
+ }
1043
+ };
1044
+
1045
+ const ingestResolvedContext = (): void => {
1046
+ ingestContextText(resolvedContext?.fullPageContent);
1047
+ ingestContextText(resolvedContext?.parentCommentContent);
1048
+ for (const siblingCommentContent of resolvedContext?.siblingCommentsContents ?? []) {
1049
+ ingestContextText(siblingCommentContent);
1050
+ }
1051
+ flushPendingSessionContext();
1052
+ flushPendingHarvestTexts();
1053
+ };
1054
+
1055
+ /**
1056
+ * Put back what this editor had contributed to the pot another editor just
1057
+ * emptied.
1058
+ *
1059
+ * The scope that ended belongs to the host that reported it, not to everyone
1060
+ * sharing these globals. Without this an editor alongside it — a comment box
1061
+ * under a chat panel that switched conversation — is left with none of its
1062
+ * learning and no way back to it: the dedupe sets below are per editor, so
1063
+ * its own page reads as already ingested and is skipped from then on.
1064
+ *
1065
+ * Nothing is re-fetched. The context this editor resolved is still held, and
1066
+ * what it describes has not changed just because another host moved on.
1067
+ *
1068
+ * Only for an editor that reported no scope. One that did is party to the
1069
+ * same scope system and the scope just dropped may well be its own: a
1070
+ * Confluence page transition mounts the incoming editor before tearing down
1071
+ * the outgoing one, and the outgoing one is still holding the page that was
1072
+ * left. Putting that back is exactly what the eviction was for.
1073
+ */
1074
+ const reprimeSessionLearning = (): void => {
1075
+ if (destroyed || resolvedContext?.contextScopeKey !== undefined) {
1076
+ return;
1077
+ }
1078
+ pendingSessionContextTexts.clear();
1079
+ sessionIngestedContextTexts.clear();
1080
+ pendingHarvestTexts.clear();
1081
+ harvestedContextTexts.clear();
1082
+ ingestResolvedContext();
1083
+ if (currentView) {
1084
+ harvestDocument(currentView.state.doc, 'reprime');
1085
+ }
1086
+ };
1087
+
1088
+ /**
1089
+ * Drop everything learned for the scope that just ended.
1090
+ *
1091
+ * Both stores are claims about what is being discussed, and neither survives
1092
+ * the discussion changing: L1 boosts would keep a page's words ranked above
1093
+ * the next page's, and the harvested set holds one conversation's content.
1094
+ * The dedupe sets go too, so the context that arrives next is treated as
1095
+ * unseen and re-primes both — which is what makes this safe to do on a page
1096
+ * change, since the transcript is re-ingested along with the new page.
1097
+ *
1098
+ * The stores are shared, so every other mounted editor is put back in the
1099
+ * same tick from context it already holds. Only the scope that ended is
1100
+ * actually dropped.
1101
+ */
1102
+ const resetSessionScopedLearning = (reason: string): void => {
1103
+ resetSessionBoosts();
1104
+ loadedInlineCodeHarvester?.resetInlineCodeHarvest();
1105
+ resolvedContext = undefined;
1106
+ pendingSessionContextTexts.clear();
1107
+ sessionIngestedContextTexts.clear();
1108
+ pendingHarvestTexts.clear();
1109
+ harvestedContextTexts.clear();
1110
+ ctcTag('init', `session-scoped learning reset · ${reason}`, CTC_STYLES.dim);
1111
+ for (const reprimeOther of mountedEditors) {
1112
+ if (reprimeOther !== reprimeSessionLearning) {
1113
+ reprimeOther();
1114
+ }
1115
+ }
1116
+ };
1117
+
689
1118
  const applyContext = (context: AutocompleteContext): void => {
1119
+ // A host that scopes its context tells us which scope each read belongs to.
1120
+ // A read with no recorded key to compare against only records: that is the
1121
+ // first since the last editor went away, so there is nothing left to throw
1122
+ // away and resetting would drop the priming focus just started. A read from
1123
+ // a newly mounted editor is not that case — the key outlives the editor
1124
+ // precisely so a host that remounts across a navigation still evicts.
1125
+ if (context.contextScopeKey !== undefined && context.contextScopeKey !== contextScopeKey) {
1126
+ if (contextScopeKey !== undefined) {
1127
+ resetSessionScopedLearning(`scope ${contextScopeKey} → ${context.contextScopeKey}`);
1128
+ }
1129
+ contextScopeKey = context.contextScopeKey;
1130
+ }
1131
+
690
1132
  // Merge rather than replace: the word-boundary retry may resolve only a
691
1133
  // late-arriving field (e.g. parentCommentContent) without re-sending
692
1134
  // fullPageContent, so replacing would drop previously resolved context.
@@ -697,28 +1139,7 @@ export const createAutocompletePlugin = (
697
1139
  );
698
1140
  resolvedContext = { ...resolvedContext, ...definedContext };
699
1141
 
700
- const ingestContextText = (text?: string): void => {
701
- if (!text || ingestedContextTexts.has(text)) {
702
- return;
703
- }
704
-
705
- ingestedContextTexts.add(text);
706
- // Evict oldest entries (Set preserves insertion order) to bound memory.
707
- while (ingestedContextTexts.size > MAX_INGESTED_CONTEXT_TEXTS) {
708
- const oldest = ingestedContextTexts.values().next().value;
709
- if (oldest === undefined) {
710
- break;
711
- }
712
- ingestedContextTexts.delete(oldest);
713
- }
714
- ingestDocumentPage(text);
715
- };
716
-
717
- ingestContextText(context.fullPageContent);
718
- ingestContextText(context.parentCommentContent);
719
- for (const siblingCommentContent of context.siblingCommentsContents ?? []) {
720
- ingestContextText(siblingCommentContent);
721
- }
1142
+ ingestResolvedContext();
722
1143
 
723
1144
  // Context arrived after word boundaries may already have fired. Re-send
724
1145
  // slow-lane context immediately so the next inference includes the thread.
@@ -741,7 +1162,20 @@ export const createAutocompletePlugin = (
741
1162
  allowThrottle?: boolean;
742
1163
  source: string;
743
1164
  }): boolean => {
744
- if (!options?.getContext || contextRequestInFlight) {
1165
+ if (!options?.getContext) {
1166
+ return false;
1167
+ }
1168
+
1169
+ if (contextRequestInFlight) {
1170
+ // Only the reads that bypass the throttle are worth keeping: those are
1171
+ // the ones the host asked for by name, and a scope change travels among
1172
+ // them with no second channel to arrive on, so losing one would strand
1173
+ // the eviction until something else happened to move. A word-boundary
1174
+ // poll is a retry loop for context that has not landed yet, and the read
1175
+ // already open will bring it.
1176
+ if (!allowThrottle) {
1177
+ queuedContextRefreshSource = source;
1178
+ }
745
1179
  return false;
746
1180
  }
747
1181
 
@@ -776,6 +1210,11 @@ export const createAutocompletePlugin = (
776
1210
  })
777
1211
  .finally(() => {
778
1212
  contextRequestInFlight = false;
1213
+ const queuedSource = queuedContextRefreshSource;
1214
+ queuedContextRefreshSource = undefined;
1215
+ if (queuedSource !== undefined && !destroyed) {
1216
+ refreshContext({ source: queuedSource, allowThrottle: false });
1217
+ }
779
1218
  });
780
1219
 
781
1220
  return true;
@@ -817,6 +1256,63 @@ export const createAutocompletePlugin = (
817
1256
  cancelActiveDecision();
818
1257
  };
819
1258
 
1259
+ /**
1260
+ * Offer a harvested inline-code surface on a prefix the scored path left
1261
+ * empty. Returns whether one was committed.
1262
+ */
1263
+ const commitHarvestedSuggestion = (view: EditorView, decision: PredictionDecision): boolean => {
1264
+ const harvester = inlineCodeHarvester;
1265
+ if (!harvester || !isInlineCodeHarvestEnabled) {
1266
+ return false;
1267
+ }
1268
+ if (!scoredPathFinishedEmpty(decision.textBefore)) {
1269
+ return false;
1270
+ }
1271
+
1272
+ const typedPrefix = getTrailingSurfaceToken(decision.textBefore);
1273
+ const match = harvester.findHarvestedCompletion(typedPrefix);
1274
+ if (!match) {
1275
+ return false;
1276
+ }
1277
+ if (
1278
+ isSurfaceInAcceptCooldown(match.surface) ||
1279
+ repeatsPrecedingText(match, decision.textBefore)
1280
+ ) {
1281
+ return false;
1282
+ }
1283
+
1284
+ const suggestion = showGhostText(
1285
+ view,
1286
+ buildHarvestPrediction(match),
1287
+ decision.position,
1288
+ decision.revision,
1289
+ decision.startedAt,
1290
+ match.typedPrefixLength,
1291
+ );
1292
+ if (!suggestion) {
1293
+ return false;
1294
+ }
1295
+
1296
+ cancelActiveDecision();
1297
+ ctcTag(
1298
+ 'harvest',
1299
+ `offered "${match.surface}" for "${typedPrefix}" · ${match.collapsedBy} · ${match.replyOccurrences} reply / ${match.documentSpans} doc sightings${
1300
+ match.rivalSurfaces.length > 0 ? ` · over ${match.rivalSurfaces.join(', ')}` : ''
1301
+ }`,
1302
+ CTC_STYLES.lm,
1303
+ );
1304
+ if (suggestion.ghostText !== lastShownGhostText) {
1305
+ lastShownGhostText = suggestion.ghostText;
1306
+ api?.analytics?.actions.fireAnalyticsEvent({
1307
+ action: ACTION.SUGGESTION_VIEWED,
1308
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
1309
+ eventType: EVENT_TYPE.TRACK,
1310
+ attributes: { completionSource: completionSourceFor(suggestion), surface },
1311
+ });
1312
+ }
1313
+ return true;
1314
+ };
1315
+
820
1316
  const evaluateActiveDecision = (view: EditorView, revision: number): void => {
821
1317
  const decision = activeDecision;
822
1318
  if (!decision || decision.revision !== revision || !isDecisionCurrent(view, decision)) {
@@ -859,8 +1355,11 @@ export const createAutocompletePlugin = (
859
1355
 
860
1356
  const prediction = predict(decision.textBefore);
861
1357
  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.
1358
+ // Only where the scored path has finished and come away with nothing
1359
+ // does a harvested surface get to answer; otherwise remain in
1360
+ // `collecting` until an async signal arrives or the hard deadline
1361
+ // expires. We never display a provisional fallback.
1362
+ commitHarvestedSuggestion(view, decision);
864
1363
  return;
865
1364
  }
866
1365
 
@@ -924,7 +1423,7 @@ export const createAutocompletePlugin = (
924
1423
  action: ACTION.SUGGESTION_VIEWED,
925
1424
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
926
1425
  eventType: EVENT_TYPE.TRACK,
927
- attributes: { completionSource: getCompletionSource(), surface },
1426
+ attributes: { completionSource: completionSourceFor(suggestion), surface },
928
1427
  });
929
1428
  }
930
1429
  } catch (error) {
@@ -1136,11 +1635,14 @@ export const createAutocompletePlugin = (
1136
1635
  });
1137
1636
  },
1138
1637
  Escape: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
1638
+ const dismissed = (
1639
+ autocompletePluginKey.getState(state) as AutocompletePluginState | undefined
1640
+ )?.suggestion;
1139
1641
  const didClear = clearGhostText(state, dispatch);
1140
1642
  if (didClear) {
1141
1643
  cancelActiveDecision();
1142
1644
  dismissedContext = getTextBeforeCursor(state);
1143
- fireSuggestionDismissedAnalytics('escape');
1645
+ fireSuggestionDismissedAnalytics('escape', dismissed ?? null);
1144
1646
  }
1145
1647
  return didClear;
1146
1648
  },
@@ -1158,7 +1660,7 @@ export const createAutocompletePlugin = (
1158
1660
  if (pluginState?.ghostText) {
1159
1661
  clearGhostText(view.state, view.dispatch);
1160
1662
  cancelActiveDecision();
1161
- fireSuggestionDismissedAnalytics('blur');
1663
+ fireSuggestionDismissedAnalytics('blur', pluginState.suggestion);
1162
1664
  }
1163
1665
  return false;
1164
1666
  },
@@ -1168,12 +1670,15 @@ export const createAutocompletePlugin = (
1168
1670
  }
1169
1671
 
1170
1672
  if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
1673
+ const dismissed = (
1674
+ autocompletePluginKey.getState(view.state) as AutocompletePluginState | undefined
1675
+ )?.suggestion;
1171
1676
  const didClear = clearGhostText(view.state, view.dispatch);
1172
1677
  if (didClear) {
1173
1678
  cancelActiveDecision();
1174
1679
  dismissedContext = getTextBeforeCursor(view.state);
1175
1680
  lastShownGhostText = '';
1176
- fireSuggestionDismissedAnalytics('click');
1681
+ fireSuggestionDismissedAnalytics('click', dismissed ?? null);
1177
1682
  }
1178
1683
  return false;
1179
1684
  }
@@ -1193,19 +1698,21 @@ export const createAutocompletePlugin = (
1193
1698
 
1194
1699
  return accepted;
1195
1700
  },
1196
- focus: () => {
1701
+ focus: (view: EditorView) => {
1197
1702
  if (!isAutocompleteEnabled) {
1198
1703
  return false;
1199
1704
  }
1200
1705
 
1201
- loadDefaultVocabulary({
1202
- isLocalLLM: options?.useLocalModel ?? false,
1203
- surface,
1204
- }).catch((error) => {
1205
- logException(error as Error, {
1206
- location: 'editor-plugin-autocomplete/loadDefaultVocabulary',
1706
+ void ensureVocabularyReady();
1707
+ // First point at which the session is known to be using the input,
1708
+ // which is where the rest of the artifacts are requested too.
1709
+ void ensureInlineCodeHarvester()
1710
+ .then(() => harvestDocument(view.state.doc, 'focus'))
1711
+ .catch((error) => {
1712
+ logException(error as Error, {
1713
+ location: 'editor-plugin-autocomplete/harvestDocument',
1714
+ });
1207
1715
  });
1208
- });
1209
1716
  loadVectorsAsync({
1210
1717
  isLocalLLM: options?.useLocalModel ?? false,
1211
1718
  surface,
@@ -1238,11 +1745,13 @@ export const createAutocompletePlugin = (
1238
1745
  // Capture up front so a subscription notification before the first PM
1239
1746
  // transaction can still drive slowLaneClient.updateContext (gated on currentView).
1240
1747
  currentView = editorView;
1748
+ mountedEditors.add(reprimeSessionLearning);
1241
1749
 
1242
1750
  // Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
1243
1751
  if (isAutocompleteEnabled && options?.subscribeToContextUpdates) {
1244
1752
  unsubscribeFromContextUpdates = options.subscribeToContextUpdates(() => {
1245
- // Bypass throttle for freshness; the in-flight guard prevents overlap.
1753
+ // Bypass throttle for freshness; a read still open defers this one
1754
+ // rather than overlapping it.
1246
1755
  refreshContext({ source: 'subscription', allowThrottle: false });
1247
1756
  });
1248
1757
  }
@@ -1274,6 +1783,11 @@ export const createAutocompletePlugin = (
1274
1783
  buildSlowLaneText(view.state.doc.textContent, resolvedContext),
1275
1784
  );
1276
1785
 
1786
+ // The backtick input rule has fired by the time a span is
1787
+ // finished, so a word boundary is the earliest point the mark
1788
+ // exists to be found.
1789
+ harvestDocument(view.state.doc, 'word-boundary');
1790
+
1277
1791
  // Context may not have resolved on first focus (e.g. comment
1278
1792
  // thread still loading). Retry on word boundaries until we have
1279
1793
  // the parent comment, throttled so we don't refetch constantly
@@ -1313,7 +1827,34 @@ export const createAutocompletePlugin = (
1313
1827
  if (hasDestroy(slowLaneClient)) {
1314
1828
  slowLaneClient.destroy();
1315
1829
  }
1316
- ingestedContextTexts.clear();
1830
+ queuedContextRefreshSource = undefined;
1831
+ pendingSessionContextTexts.clear();
1832
+ sessionIngestedContextTexts.clear();
1833
+ pendingHarvestTexts.clear();
1834
+ harvestedContextTexts.clear();
1835
+ // The harvested set holds one conversation's user and assistant
1836
+ // content, and the L1 boosts describe what that conversation was
1837
+ // about. Both live at module scope, so they have to be emptied here
1838
+ // or they leak into whichever editor mounts next — but only once no
1839
+ // editor is left to be using them. The scope key goes with them, and
1840
+ // only with them: clearing it while another editor is still mounted
1841
+ // would leave that editor's next read looking like a first one, with
1842
+ // nothing to compare against and everything still loaded.
1843
+ mountedEditors.delete(reprimeSessionLearning);
1844
+ if (mountedEditors.size === 0) {
1845
+ loadedInlineCodeHarvester?.resetInlineCodeHarvest();
1846
+ // Only for a host that scopes its context, which is the one asking
1847
+ // for its learning to end with the conversation. A host that sends
1848
+ // no scope has boosts belonging to the page it is on, and that page
1849
+ // outlives an editor being unmounted — a Confluence comment box is
1850
+ // closed far more often than the page under it changes.
1851
+ if (contextScopeKey !== undefined) {
1852
+ resetSessionBoosts();
1853
+ contextScopeKey = undefined;
1854
+ }
1855
+ }
1856
+ inlineCodeHarvester = null;
1857
+ inlineCodeHarvesterPromise = undefined;
1317
1858
  },
1318
1859
  };
1319
1860
  },