@atlaskit/editor-plugin-autocomplete 9.1.0 → 9.2.1

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.
@@ -6,10 +6,13 @@ import { PluginKey } from '@atlaskit/editor-prosemirror/state';
6
6
  import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
7
7
  import { CTC_STYLES, ctcTag, isAutocompleteDebugEnabled, isAutocompleteDebugVerbose } from './debug-mode';
8
8
  import { createGhostTextDecorationSet } from './ghost-text-decoration';
9
+ // Type-only, so the harvester chunk is still reached exclusively through the
10
+ // dynamic import in `ensureInlineCodeHarvester`.
11
+
9
12
  import { createLocalSlowLaneClient } from './local-slow-lane-client';
10
13
  import { loadGrammarDataAsync } from './scoring-pipeline';
11
14
  import { clearDefaultSlowLaneClient, createSlowLaneClient, getDefaultSlowLaneClientStatus, isWordBoundary, setDefaultSlowLaneClient } from './slow-lane-client';
12
- import { predict, loadDefaultVocabulary, loadVectorsAsync, incrementSessionFreq, ingestDocumentPage, getLastPredictionDebug, noteSuggestionAccepted } from './text-predictor';
15
+ import { predict, loadDefaultVocabulary, loadVectorsAsync, incrementSessionFreq, ingestDocumentPage, getLastPredictionDebug, getLastPredictionOutcome, isSurfaceInAcceptCooldown, noteSuggestionAccepted, resetSessionBoosts } from './text-predictor';
13
16
  export const autocompletePluginKey = new PluginKey('autocomplete');
14
17
  const PREDICTION_COALESCE_MS = 0;
15
18
  const GHOST_DECISION_BUDGET_MS = 100;
@@ -20,6 +23,32 @@ const CONTEXT_REFRESH_THROTTLE_MS = 1000;
20
23
  // version of the (potentially hundreds-of-KB) page content for the plugin's
21
24
  // lifetime. Eviction is FIFO; a re-ingest of an evicted text is harmless.
22
25
  const MAX_INGESTED_CONTEXT_TEXTS = 50;
26
+ // Re-prime callback of every mounted editor. The harvested set and the L1
27
+ // boosts are shared by all of them, and more than one is mounted routinely — a
28
+ // comment box alongside its replies, or a comment box alongside the chat input.
29
+ // Membership doubles as the mount count, so a teardown only clears the shared
30
+ // stores once the last editor has gone.
31
+ //
32
+ // Decision: one pot, shared across hosts. Terms harvested in a comment box can
33
+ // be offered in the chat input and the other way round. Both are content from
34
+ // the page the reader is looking at, so the mixing is between things they can
35
+ // already see; partitioning the boosts would mean a per-host overlay on every
36
+ // trie node, which the scoring path reads on every keystroke.
37
+ const mountedEditors = new Set();
38
+ // The scope that shared learning belongs to, as last reported by a host that
39
+ // scopes its context. Held here rather than per editor because the data it
40
+ // guards is shared and some hosts replace one editor with another across a
41
+ // navigation: an instance-local key would read as unset on exactly the change it
42
+ // exists to catch, while the previous scope's terms carried on in these globals.
43
+ // Undefined until a scoping host says otherwise. An editor that sends no key
44
+ // still shares the pot being emptied, so it is re-primed from the context it
45
+ // already holds rather than left with learning it can no longer see.
46
+ let contextScopeKey;
47
+ // Set by whichever editor first finished importing the harvester chunk. The
48
+ // reset paths go through this rather than their own copy, because the editor
49
+ // that has to clear the set is not always one that imported it — the set is one
50
+ // per module, and nothing can be in it unless some editor got this far.
51
+ let loadedInlineCodeHarvester = null;
23
52
  // Caps how many times the word-boundary path will retry getContext() while the
24
53
  // parent comment is still missing. Combined with the 1s throttle this gives a
25
54
  // ~5s window to cover a still-loading comment thread, then stops permanently so
@@ -94,11 +123,16 @@ const advanceGhostThroughTypedCharacter = (tr, pluginState) => {
94
123
  ghostPosition: nextPosition,
95
124
  decorationSet: createGhostTextDecorationSet(tr.doc, nextPosition, remaining),
96
125
  // `surface` stays the full candidate so cooldown and analytics still
97
- // describe the suggestion that was originally committed.
126
+ // describe the suggestion that was originally committed. The replaced
127
+ // prefix grows with the keystroke: it is the run the insertion overwrites,
128
+ // and the user has just typed one more character of it.
98
129
  suggestion: {
99
130
  ...suggestion,
100
131
  ghostText: remaining,
101
- position: nextPosition
132
+ position: nextPosition,
133
+ ...(suggestion.replacesTypedPrefixLength === undefined ? {} : {
134
+ replacesTypedPrefixLength: suggestion.replacesTypedPrefixLength + 1
135
+ })
102
136
  }
103
137
  };
104
138
  };
@@ -135,10 +169,11 @@ const getTextBeforeCursor = state => {
135
169
  }
136
170
  return fullText.slice(-maxChars);
137
171
  };
138
- const getTrailingSurfacePrefixLength = text => {
172
+ const getTrailingSurfaceToken = text => {
139
173
  var _text$trimEnd$match$, _text$trimEnd$match;
140
- return (_text$trimEnd$match$ = (_text$trimEnd$match = text.trimEnd().match(TRAILING_SURFACE_PREFIX_REGEX)) === null || _text$trimEnd$match === void 0 ? void 0 : _text$trimEnd$match[0].length) !== null && _text$trimEnd$match$ !== void 0 ? _text$trimEnd$match$ : 0;
174
+ return (_text$trimEnd$match$ = (_text$trimEnd$match = text.trimEnd().match(TRAILING_SURFACE_PREFIX_REGEX)) === null || _text$trimEnd$match === void 0 ? void 0 : _text$trimEnd$match[0]) !== null && _text$trimEnd$match$ !== void 0 ? _text$trimEnd$match$ : '';
141
175
  };
176
+ const getTrailingSurfacePrefixLength = text => getTrailingSurfaceToken(text).length;
142
177
 
143
178
  /**
144
179
  * Set the autocomplete state via a transaction metadata.
@@ -151,13 +186,16 @@ const setAutocompleteMeta = (tr, meta) => {
151
186
  * Apply a ghost text suggestion to the editor state.
152
187
  */
153
188
  let lastShownGhostText = '';
154
- const showGhostText = (view, prediction, position, revision, decisionStartedAt) => {
189
+ const showGhostText = (view, prediction, position, revision, decisionStartedAt, replacesTypedPrefixLength) => {
155
190
  try {
156
191
  const {
157
192
  state,
158
193
  dispatch
159
194
  } = view;
160
195
  const suggestion = {
196
+ ...(replacesTypedPrefixLength === undefined ? {} : {
197
+ replacesTypedPrefixLength
198
+ }),
161
199
  decisionLatencyMs: performance.now() - decisionStartedAt,
162
200
  evidenceTier: prediction.evidenceTier,
163
201
  ghostText: prediction.text,
@@ -226,12 +264,33 @@ const acceptGhostText = (state, dispatch) => {
226
264
  }
227
265
  if (dispatch) {
228
266
  try {
267
+ var _suggestion$replacesT;
229
268
  const {
230
269
  ghostText,
231
270
  ghostPosition,
232
271
  suggestion
233
272
  } = pluginState;
234
- let tr = state.tr.insertText(ghostText, ghostPosition);
273
+ // Replacing back over the typed prefix rewrites it in the surface's own
274
+ // casing; every other path appends and leaves the prefix untouched.
275
+ const replaceFrom = Math.max(0, ghostPosition - Math.min((_suggestion$replacesT = suggestion.replacesTypedPrefixLength) !== null && _suggestion$replacesT !== void 0 ? _suggestion$replacesT : 0, ghostPosition));
276
+ let tr = state.tr;
277
+ if (replaceFrom === ghostPosition) {
278
+ tr = tr.insertText(ghostText, ghostPosition);
279
+ } else {
280
+ tr = tr.insertText(suggestion.surface, replaceFrom, ghostPosition);
281
+ // Only the harvested path replaces, and what authorized it was the
282
+ // surface being marked as code somewhere in the session. Inserting it
283
+ // as plain text loses that, so the identifier the user accepted reads
284
+ // as prose while the same identifier they typed by hand does not.
285
+ const codeMark = state.schema.marks.code;
286
+ if (codeMark) {
287
+ tr = tr.addMark(replaceFrom, replaceFrom + suggestion.surface.length, codeMark.create());
288
+ // The backtick input rule never ran, so nothing else will close this
289
+ // mark. Without dropping it from the stored set the next character
290
+ // the user types continues the code span.
291
+ tr = tr.removeStoredMark(codeMark);
292
+ }
293
+ }
235
294
  tr = setAutocompleteMeta(tr, {
236
295
  ghostText: '',
237
296
  ghostPosition: -1,
@@ -319,6 +378,69 @@ const getLeadingTextCharacter = text => {
319
378
  }
320
379
  return String.fromCodePoint(firstCodePoint);
321
380
  };
381
+
382
+ /**
383
+ * The one verdict a harvested surface may speak on.
384
+ *
385
+ * `no-candidate` is the only abstention that means no vocabulary reaches the
386
+ * prefix at all. Every other one — a margin the model cannot clear, a rival
387
+ * still being read — describes known words in contention, where a session
388
+ * surface with no score behind it would be overruling the ranker rather than
389
+ * filling a gap it left.
390
+ */
391
+ const HARVEST_ELIGIBLE_ABSTAIN_REASON = 'no-candidate';
392
+
393
+ /**
394
+ * Whether the scored path has finished with this exact prefix and found nothing.
395
+ *
396
+ * The 100ms deadline expiring is not the same answer: it means the ranker was
397
+ * still working, and displaying then would race a suggestion that is about to
398
+ * arrive. So the harvest path reads the recorded verdict rather than the clock.
399
+ */
400
+ const scoredPathFinishedEmpty = textBefore => {
401
+ const outcome = getLastPredictionOutcome();
402
+ return outcome !== null && outcome.textBefore === textBefore && !outcome.awaitingAsyncEvidence && outcome.abstainReason === HARVEST_ELIGIBLE_ABSTAIN_REASON;
403
+ };
404
+
405
+ /**
406
+ * Whether the surface is already sitting immediately before the typed prefix.
407
+ *
408
+ * Completing `ml-s` to `ml-studio` right after `ml-studio` produces the echo the
409
+ * scored path's repetition guard exists to stop, and this path does not go
410
+ * through arbitration to inherit it.
411
+ */
412
+ const repeatsPrecedingText = (match, textBefore) => {
413
+ const trimmed = textBefore.trimEnd();
414
+ const preceding = trimmed.slice(0, trimmed.length - match.typedPrefixLength).trimEnd();
415
+ return preceding.toLowerCase().endsWith(match.surface.toLowerCase());
416
+ };
417
+
418
+ /**
419
+ * Dress a harvested match as a prediction so it commits through the same path as
420
+ * a scored one.
421
+ *
422
+ * The scoring fields are zeroed rather than invented: there is no posterior, no
423
+ * margin and no shortlist behind this surface, and `session-harvest` on the
424
+ * evidence tier is what says so wherever the snapshot is read.
425
+ */
426
+ const buildHarvestPrediction = match => ({
427
+ evidenceDepth: {
428
+ totalChars: 0,
429
+ totalTokens: 0,
430
+ verifiedChars: 0,
431
+ verifiedTokens: 0
432
+ },
433
+ evidenceTier: 'session-harvest',
434
+ meanTokenLogProbability: 0,
435
+ poolHeldExtension: false,
436
+ posterior: 0,
437
+ rankScore: 0,
438
+ shortlistSize: 0,
439
+ surface: match.surface,
440
+ termType: 'word',
441
+ text: match.ghostText,
442
+ winnerMargin: 0
443
+ });
322
444
  const isEnglishLocale = locale => {
323
445
  if (!locale) {
324
446
  return false;
@@ -330,6 +452,7 @@ export const createAutocompletePlugin = (options, api) => {
330
452
  const surface = (_options$surface = options === null || options === void 0 ? void 0 : options.surface) !== null && _options$surface !== void 0 ? _options$surface : 'editor';
331
453
  const locale = (_options$locale = options === null || options === void 0 ? void 0 : options.locale) !== null && _options$locale !== void 0 ? _options$locale : typeof navigator !== 'undefined' ? navigator.language : undefined;
332
454
  const isAutocompleteEnabled = isEnglishLocale(locale);
455
+ const isInlineCodeHarvestEnabled = isAutocompleteEnabled && (options === null || options === void 0 ? void 0 : options.harvestInlineCode) === true;
333
456
  let debounceTimer = null;
334
457
  let decisionDeadlineTimer = null;
335
458
  let hasIngestedPage = false;
@@ -372,14 +495,24 @@ export const createAutocompletePlugin = (options, api) => {
372
495
  }
373
496
  return options !== null && options !== void 0 && options.useLocalModel ? 'localLlm' : 'server';
374
497
  };
375
- const fireSuggestionDismissedAnalytics = reason => {
498
+
499
+ /**
500
+ * Which path produced a given suggestion.
501
+ *
502
+ * A harvested surface is reported as `harvest` rather than by the slow-lane
503
+ * state it happened to be shown under, so its views and acceptances stay
504
+ * separable from the scored path's and cannot quietly move the headline
505
+ * acceptance rate.
506
+ */
507
+ const completionSourceFor = suggestion => (suggestion === null || suggestion === void 0 ? void 0 : suggestion.evidenceTier) === 'session-harvest' ? 'harvest' : getCompletionSource();
508
+ const fireSuggestionDismissedAnalytics = (reason, suggestion) => {
376
509
  var _api$analytics;
377
510
  api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions.fireAnalyticsEvent({
378
511
  action: ACTION.SUGGESTION_DISMISSED,
379
512
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
380
513
  eventType: EVENT_TYPE.TRACK,
381
514
  attributes: {
382
- completionSource: getCompletionSource(),
515
+ completionSource: completionSourceFor(suggestion),
383
516
  reason,
384
517
  surface
385
518
  }
@@ -436,7 +569,7 @@ export const createAutocompletePlugin = (options, api) => {
436
569
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
437
570
  eventType: EVENT_TYPE.TRACK,
438
571
  attributes: {
439
- completionSource: getCompletionSource(),
572
+ completionSource: completionSourceFor(suggestion),
440
573
  suggestionLength,
441
574
  typedLength,
442
575
  kssDelta,
@@ -504,6 +637,10 @@ export const createAutocompletePlugin = (options, api) => {
504
637
  const registeredSlowLaneStatus = getDefaultSlowLaneClientStatus();
505
638
  ctcTag('init', `slow lane registered · id=${slowLaneClientId} · selected=${slowLaneClientKind} · canonical=${registeredSlowLaneStatus.canonicalScoringSupported ? 'yes' : 'no'} · ready=${(_registeredSlowLaneSt = registeredSlowLaneStatus.localModelReady) !== null && _registeredSlowLaneSt !== void 0 ? _registeredSlowLaneSt : 'n/a'}`, slowLaneClientKind === 'localLlm' ? CTC_STYLES.good : CTC_STYLES.warn);
506
639
  let contextRequestInFlight = false;
640
+ // A read the host asked for while another was open, kept so it can be made
641
+ // once that one settles. One slot, not a queue: a burst of notifications only
642
+ // ever means "read again", and the last of them describes the current state.
643
+ let queuedContextRefreshSource;
507
644
  let lastContextRefreshAt = 0;
508
645
  // Bounds the word-boundary retry loop so it terminates even when the editor is
509
646
  // not in a comment thread (parentCommentContent never resolves).
@@ -520,6 +657,17 @@ export const createAutocompletePlugin = (options, api) => {
520
657
  const sessionIngestedContextTexts = new Set();
521
658
  let isVocabularyReady = false;
522
659
  let vocabularyLoadPromise;
660
+
661
+ // Context text waiting on the harvester chunk, the vocabulary, or both. The
662
+ // harvester drops surfaces the vocabulary already holds, so intake before the
663
+ // load has settled would admit ordinary words no one needs completed.
664
+ const pendingHarvestTexts = new Set();
665
+ // Separate from `sessionIngestedContextTexts` because the two consumers settle
666
+ // at different times: reply occurrences accumulate, so text fed twice would
667
+ // count twice.
668
+ const harvestedContextTexts = new Set();
669
+ let inlineCodeHarvester = null;
670
+ let inlineCodeHarvesterPromise;
523
671
  const addBoundedContextText = (texts, text) => {
524
672
  texts.add(text);
525
673
  // Evict oldest entries (Set preserves insertion order) to bound memory.
@@ -543,10 +691,80 @@ export const createAutocompletePlugin = (options, api) => {
543
691
  pendingSessionContextTexts.delete(text);
544
692
  }
545
693
  };
694
+ const flushPendingHarvestTexts = () => {
695
+ const harvester = inlineCodeHarvester;
696
+ if (!harvester || !isVocabularyReady || destroyed || pendingHarvestTexts.size === 0) {
697
+ return;
698
+ }
699
+ for (const text of pendingHarvestTexts) {
700
+ if (!harvestedContextTexts.has(text)) {
701
+ harvester.harvestInlineCodeFromText(text);
702
+ addBoundedContextText(harvestedContextTexts, text);
703
+ }
704
+ pendingHarvestTexts.delete(text);
705
+ }
706
+ harvester.logInlineCodeHarvest('context');
707
+ };
708
+
709
+ /**
710
+ * Load the harvester chunk, once, and only for a host that asked for it.
711
+ *
712
+ * Kept off the critical path in the same way the vocabulary and vector loads
713
+ * are: nothing is requested until the editor is focused, so a session that
714
+ * never types in the chat pays nothing for the feature.
715
+ */
716
+ const ensureInlineCodeHarvester = () => {
717
+ if (!isInlineCodeHarvestEnabled || destroyed) {
718
+ return Promise.resolve(null);
719
+ }
720
+ inlineCodeHarvesterPromise ??= import( /* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-inline-code-harvester" */
721
+ './inline-code-harvester').then(module => {
722
+ if (destroyed) {
723
+ return null;
724
+ }
725
+ inlineCodeHarvester = module;
726
+ loadedInlineCodeHarvester = module;
727
+ flushPendingHarvestTexts();
728
+ return module;
729
+ }).catch(error => {
730
+ // Clear the promise so a later focus can retry; the queued text is
731
+ // still held and is fed by whichever attempt succeeds.
732
+ inlineCodeHarvesterPromise = undefined;
733
+ logException(error, {
734
+ location: 'editor-plugin-autocomplete/loadInlineCodeHarvester'
735
+ });
736
+ return null;
737
+ });
738
+ return inlineCodeHarvesterPromise;
739
+ };
740
+
741
+ /**
742
+ * Re-read the document's code-marked spans.
743
+ *
744
+ * The backtick input rule consumes both delimiters on the closing tick, so a
745
+ * finished span in the live document is only findable through its mark — and
746
+ * only by walking, since a mark carries no notification.
747
+ */
748
+ const harvestDocument = (doc, trigger) => {
749
+ if (!isInlineCodeHarvestEnabled || destroyed) {
750
+ return;
751
+ }
752
+ const harvester = inlineCodeHarvester;
753
+ if (!harvester) {
754
+ void ensureInlineCodeHarvester();
755
+ return;
756
+ }
757
+ if (!isVocabularyReady) {
758
+ return;
759
+ }
760
+ harvester.harvestInlineCodeFromDoc(doc);
761
+ harvester.logInlineCodeHarvest(trigger);
762
+ };
546
763
  const ensureVocabularyReady = () => {
547
764
  var _options$useLocalMode;
548
765
  if (isVocabularyReady) {
549
766
  flushPendingSessionContext();
767
+ flushPendingHarvestTexts();
550
768
  return Promise.resolve();
551
769
  }
552
770
  vocabularyLoadPromise ??= loadDefaultVocabulary({
@@ -558,6 +776,7 @@ export const createAutocompletePlugin = (options, api) => {
558
776
  }
559
777
  isVocabularyReady = true;
560
778
  flushPendingSessionContext();
779
+ flushPendingHarvestTexts();
561
780
  }).catch(error => {
562
781
  // Do not consume the pending text on failure. A later focus retries the
563
782
  // load and can still apply the original context exactly once.
@@ -577,13 +796,117 @@ export const createAutocompletePlugin = (options, api) => {
577
796
  // eslint-disable-next-line no-console
578
797
  console.log('%c[CTC:signal]%c getContext resolved', CTC_STYLES.brand, CTC_STYLES.body, {
579
798
  source,
799
+ scope: context === null || context === void 0 ? void 0 : context.contextScopeKey,
580
800
  hasParentComment: !!(context !== null && context !== void 0 && context.parentCommentContent),
581
801
  parentCommentPreview: context === null || context === void 0 ? void 0 : (_context$parentCommen = context.parentCommentContent) === null || _context$parentCommen === void 0 ? void 0 : _context$parentCommen.slice(0, 80),
582
802
  siblingCount: (_context$siblingComme4 = context === null || context === void 0 ? void 0 : (_context$siblingComme5 = context.siblingCommentsContents) === null || _context$siblingComme5 === void 0 ? void 0 : _context$siblingComme5.length) !== null && _context$siblingComme4 !== void 0 ? _context$siblingComme4 : 0,
583
803
  hasFullPage: !!(context !== null && context !== void 0 && context.fullPageContent)
584
804
  });
585
805
  };
806
+ const ingestContextText = text => {
807
+ if (!text) {
808
+ return;
809
+ }
810
+ if (!sessionIngestedContextTexts.has(text)) {
811
+ addBoundedContextText(pendingSessionContextTexts, text);
812
+ }
813
+ // Queued unconditionally rather than only once the chunk is present:
814
+ // context usually resolves in the same tick the load starts, and text
815
+ // dropped for arriving early is a reply that can never be harvested.
816
+ if (isInlineCodeHarvestEnabled && !harvestedContextTexts.has(text)) {
817
+ addBoundedContextText(pendingHarvestTexts, text);
818
+ }
819
+ };
820
+ const ingestResolvedContext = () => {
821
+ var _resolvedContext, _resolvedContext2;
822
+ ingestContextText((_resolvedContext = resolvedContext) === null || _resolvedContext === void 0 ? void 0 : _resolvedContext.fullPageContent);
823
+ ingestContextText((_resolvedContext2 = resolvedContext) === null || _resolvedContext2 === void 0 ? void 0 : _resolvedContext2.parentCommentContent);
824
+ for (const siblingCommentContent of (_resolvedContext$sibl = (_resolvedContext3 = resolvedContext) === null || _resolvedContext3 === void 0 ? void 0 : _resolvedContext3.siblingCommentsContents) !== null && _resolvedContext$sibl !== void 0 ? _resolvedContext$sibl : []) {
825
+ var _resolvedContext$sibl, _resolvedContext3;
826
+ ingestContextText(siblingCommentContent);
827
+ }
828
+ flushPendingSessionContext();
829
+ flushPendingHarvestTexts();
830
+ };
831
+
832
+ /**
833
+ * Put back what this editor had contributed to the pot another editor just
834
+ * emptied.
835
+ *
836
+ * The scope that ended belongs to the host that reported it, not to everyone
837
+ * sharing these globals. Without this an editor alongside it — a comment box
838
+ * under a chat panel that switched conversation — is left with none of its
839
+ * learning and no way back to it: the dedupe sets below are per editor, so
840
+ * its own page reads as already ingested and is skipped from then on.
841
+ *
842
+ * Nothing is re-fetched. The context this editor resolved is still held, and
843
+ * what it describes has not changed just because another host moved on.
844
+ *
845
+ * Only for an editor that reported no scope. One that did is party to the
846
+ * same scope system and the scope just dropped may well be its own: a
847
+ * Confluence page transition mounts the incoming editor before tearing down
848
+ * the outgoing one, and the outgoing one is still holding the page that was
849
+ * left. Putting that back is exactly what the eviction was for.
850
+ */
851
+ const reprimeSessionLearning = () => {
852
+ var _resolvedContext4;
853
+ if (destroyed || ((_resolvedContext4 = resolvedContext) === null || _resolvedContext4 === void 0 ? void 0 : _resolvedContext4.contextScopeKey) !== undefined) {
854
+ return;
855
+ }
856
+ pendingSessionContextTexts.clear();
857
+ sessionIngestedContextTexts.clear();
858
+ pendingHarvestTexts.clear();
859
+ harvestedContextTexts.clear();
860
+ ingestResolvedContext();
861
+ if (currentView) {
862
+ harvestDocument(currentView.state.doc, 'reprime');
863
+ }
864
+ };
865
+
866
+ /**
867
+ * Drop everything learned for the scope that just ended.
868
+ *
869
+ * Both stores are claims about what is being discussed, and neither survives
870
+ * the discussion changing: L1 boosts would keep a page's words ranked above
871
+ * the next page's, and the harvested set holds one conversation's content.
872
+ * The dedupe sets go too, so the context that arrives next is treated as
873
+ * unseen and re-primes both — which is what makes this safe to do on a page
874
+ * change, since the transcript is re-ingested along with the new page.
875
+ *
876
+ * The stores are shared, so every other mounted editor is put back in the
877
+ * same tick from context it already holds. Only the scope that ended is
878
+ * actually dropped.
879
+ */
880
+ const resetSessionScopedLearning = reason => {
881
+ var _loadedInlineCodeHarv;
882
+ resetSessionBoosts();
883
+ (_loadedInlineCodeHarv = loadedInlineCodeHarvester) === null || _loadedInlineCodeHarv === void 0 ? void 0 : _loadedInlineCodeHarv.resetInlineCodeHarvest();
884
+ resolvedContext = undefined;
885
+ pendingSessionContextTexts.clear();
886
+ sessionIngestedContextTexts.clear();
887
+ pendingHarvestTexts.clear();
888
+ harvestedContextTexts.clear();
889
+ ctcTag('init', `session-scoped learning reset · ${reason}`, CTC_STYLES.dim);
890
+ for (const reprimeOther of mountedEditors) {
891
+ if (reprimeOther !== reprimeSessionLearning) {
892
+ reprimeOther();
893
+ }
894
+ }
895
+ };
586
896
  const applyContext = context => {
897
+ // A host that scopes its context tells us which scope each read belongs to.
898
+ // A read with no recorded key to compare against only records: that is the
899
+ // first since the last editor went away, so there is nothing left to throw
900
+ // away and resetting would drop the priming focus just started. A read from
901
+ // a newly mounted editor is not that case — the key outlives the editor
902
+ // precisely so a host that remounts across a navigation still evicts.
903
+ if (context.contextScopeKey !== undefined && context.contextScopeKey !== contextScopeKey) {
904
+ if (contextScopeKey !== undefined) {
905
+ resetSessionScopedLearning(`scope ${contextScopeKey} → ${context.contextScopeKey}`);
906
+ }
907
+ contextScopeKey = context.contextScopeKey;
908
+ }
909
+
587
910
  // Merge rather than replace: the word-boundary retry may resolve only a
588
911
  // late-arriving field (e.g. parentCommentContent) without re-sending
589
912
  // fullPageContent, so replacing would drop previously resolved context.
@@ -594,19 +917,7 @@ export const createAutocompletePlugin = (options, api) => {
594
917
  ...resolvedContext,
595
918
  ...definedContext
596
919
  };
597
- const ingestContextText = text => {
598
- if (!text || sessionIngestedContextTexts.has(text)) {
599
- return;
600
- }
601
- addBoundedContextText(pendingSessionContextTexts, text);
602
- };
603
- ingestContextText(context.fullPageContent);
604
- ingestContextText(context.parentCommentContent);
605
- for (const siblingCommentContent of (_context$siblingComme6 = context.siblingCommentsContents) !== null && _context$siblingComme6 !== void 0 ? _context$siblingComme6 : []) {
606
- var _context$siblingComme6;
607
- ingestContextText(siblingCommentContent);
608
- }
609
- flushPendingSessionContext();
920
+ ingestResolvedContext();
610
921
 
611
922
  // Context arrived after word boundaries may already have fired. Re-send
612
923
  // slow-lane context immediately so the next inference includes the thread.
@@ -624,7 +935,19 @@ export const createAutocompletePlugin = (options, api) => {
624
935
  source,
625
936
  allowThrottle = true
626
937
  }) => {
627
- if (!(options !== null && options !== void 0 && options.getContext) || contextRequestInFlight) {
938
+ if (!(options !== null && options !== void 0 && options.getContext)) {
939
+ return false;
940
+ }
941
+ if (contextRequestInFlight) {
942
+ // Only the reads that bypass the throttle are worth keeping: those are
943
+ // the ones the host asked for by name, and a scope change travels among
944
+ // them with no second channel to arrive on, so losing one would strand
945
+ // the eviction until something else happened to move. A word-boundary
946
+ // poll is a retry loop for context that has not landed yet, and the read
947
+ // already open will bring it.
948
+ if (!allowThrottle) {
949
+ queuedContextRefreshSource = source;
950
+ }
628
951
  return false;
629
952
  }
630
953
  const now = Date.now();
@@ -651,6 +974,14 @@ export const createAutocompletePlugin = (options, api) => {
651
974
  });
652
975
  }).finally(() => {
653
976
  contextRequestInFlight = false;
977
+ const queuedSource = queuedContextRefreshSource;
978
+ queuedContextRefreshSource = undefined;
979
+ if (queuedSource !== undefined && !destroyed) {
980
+ refreshContext({
981
+ source: queuedSource,
982
+ allowThrottle: false
983
+ });
984
+ }
654
985
  });
655
986
  return true;
656
987
  };
@@ -685,6 +1016,48 @@ export const createAutocompletePlugin = (options, api) => {
685
1016
  }
686
1017
  cancelActiveDecision();
687
1018
  };
1019
+
1020
+ /**
1021
+ * Offer a harvested inline-code surface on a prefix the scored path left
1022
+ * empty. Returns whether one was committed.
1023
+ */
1024
+ const commitHarvestedSuggestion = (view, decision) => {
1025
+ const harvester = inlineCodeHarvester;
1026
+ if (!harvester || !isInlineCodeHarvestEnabled) {
1027
+ return false;
1028
+ }
1029
+ if (!scoredPathFinishedEmpty(decision.textBefore)) {
1030
+ return false;
1031
+ }
1032
+ const typedPrefix = getTrailingSurfaceToken(decision.textBefore);
1033
+ const match = harvester.findHarvestedCompletion(typedPrefix);
1034
+ if (!match) {
1035
+ return false;
1036
+ }
1037
+ if (isSurfaceInAcceptCooldown(match.surface) || repeatsPrecedingText(match, decision.textBefore)) {
1038
+ return false;
1039
+ }
1040
+ const suggestion = showGhostText(view, buildHarvestPrediction(match), decision.position, decision.revision, decision.startedAt, match.typedPrefixLength);
1041
+ if (!suggestion) {
1042
+ return false;
1043
+ }
1044
+ cancelActiveDecision();
1045
+ ctcTag('harvest', `offered "${match.surface}" for "${typedPrefix}" · ${match.collapsedBy} · ${match.replyOccurrences} reply / ${match.documentSpans} doc sightings${match.rivalSurfaces.length > 0 ? ` · over ${match.rivalSurfaces.join(', ')}` : ''}`, CTC_STYLES.lm);
1046
+ if (suggestion.ghostText !== lastShownGhostText) {
1047
+ var _api$analytics5;
1048
+ lastShownGhostText = suggestion.ghostText;
1049
+ api === null || api === void 0 ? void 0 : (_api$analytics5 = api.analytics) === null || _api$analytics5 === void 0 ? void 0 : _api$analytics5.actions.fireAnalyticsEvent({
1050
+ action: ACTION.SUGGESTION_VIEWED,
1051
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
1052
+ eventType: EVENT_TYPE.TRACK,
1053
+ attributes: {
1054
+ completionSource: completionSourceFor(suggestion),
1055
+ surface
1056
+ }
1057
+ });
1058
+ }
1059
+ return true;
1060
+ };
688
1061
  const evaluateActiveDecision = (view, revision) => {
689
1062
  const decision = activeDecision;
690
1063
  if (!decision || decision.revision !== revision || !isDecisionCurrent(view, decision)) {
@@ -725,8 +1098,11 @@ export const createAutocompletePlugin = (options, api) => {
725
1098
  }
726
1099
  const prediction = predict(decision.textBefore);
727
1100
  if (!prediction || prediction.text.length === 0) {
728
- // Remain in `collecting` until an async signal arrives or the hard
729
- // deadline expires. We never display a provisional fallback.
1101
+ // Only where the scored path has finished and come away with nothing
1102
+ // does a harvested surface get to answer; otherwise remain in
1103
+ // `collecting` until an async signal arrives or the hard deadline
1104
+ // expires. We never display a provisional fallback.
1105
+ commitHarvestedSuggestion(view, decision);
730
1106
  return;
731
1107
  }
732
1108
  const readyAtMs = performance.now() - decision.startedAt;
@@ -766,14 +1142,14 @@ export const createAutocompletePlugin = (options, api) => {
766
1142
  });
767
1143
  }
768
1144
  if (suggestion.ghostText !== lastShownGhostText) {
769
- var _api$analytics5;
1145
+ var _api$analytics6;
770
1146
  lastShownGhostText = suggestion.ghostText;
771
- api === null || api === void 0 ? void 0 : (_api$analytics5 = api.analytics) === null || _api$analytics5 === void 0 ? void 0 : _api$analytics5.actions.fireAnalyticsEvent({
1147
+ api === null || api === void 0 ? void 0 : (_api$analytics6 = api.analytics) === null || _api$analytics6 === void 0 ? void 0 : _api$analytics6.actions.fireAnalyticsEvent({
772
1148
  action: ACTION.SUGGESTION_VIEWED,
773
1149
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
774
1150
  eventType: EVENT_TYPE.TRACK,
775
1151
  attributes: {
776
- completionSource: getCompletionSource(),
1152
+ completionSource: completionSourceFor(suggestion),
777
1153
  surface
778
1154
  }
779
1155
  });
@@ -961,11 +1337,13 @@ export const createAutocompletePlugin = (options, api) => {
961
1337
  });
962
1338
  },
963
1339
  Escape: (state, dispatch) => {
1340
+ var _autocompletePluginKe;
1341
+ const dismissed = (_autocompletePluginKe = autocompletePluginKey.getState(state)) === null || _autocompletePluginKe === void 0 ? void 0 : _autocompletePluginKe.suggestion;
964
1342
  const didClear = clearGhostText(state, dispatch);
965
1343
  if (didClear) {
966
1344
  cancelActiveDecision();
967
1345
  dismissedContext = getTextBeforeCursor(state);
968
- fireSuggestionDismissedAnalytics('escape');
1346
+ fireSuggestionDismissedAnalytics('escape', dismissed !== null && dismissed !== void 0 ? dismissed : null);
969
1347
  }
970
1348
  return didClear;
971
1349
  }
@@ -979,7 +1357,7 @@ export const createAutocompletePlugin = (options, api) => {
979
1357
  if (pluginState !== null && pluginState !== void 0 && pluginState.ghostText) {
980
1358
  clearGhostText(view.state, view.dispatch);
981
1359
  cancelActiveDecision();
982
- fireSuggestionDismissedAnalytics('blur');
1360
+ fireSuggestionDismissedAnalytics('blur', pluginState.suggestion);
983
1361
  }
984
1362
  return false;
985
1363
  },
@@ -988,12 +1366,14 @@ export const createAutocompletePlugin = (options, api) => {
988
1366
  return false;
989
1367
  }
990
1368
  if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
1369
+ var _autocompletePluginKe2;
1370
+ const dismissed = (_autocompletePluginKe2 = autocompletePluginKey.getState(view.state)) === null || _autocompletePluginKe2 === void 0 ? void 0 : _autocompletePluginKe2.suggestion;
991
1371
  const didClear = clearGhostText(view.state, view.dispatch);
992
1372
  if (didClear) {
993
1373
  cancelActiveDecision();
994
1374
  dismissedContext = getTextBeforeCursor(view.state);
995
1375
  lastShownGhostText = '';
996
- fireSuggestionDismissedAnalytics('click');
1376
+ fireSuggestionDismissedAnalytics('click', dismissed !== null && dismissed !== void 0 ? dismissed : null);
997
1377
  }
998
1378
  return false;
999
1379
  }
@@ -1009,12 +1389,19 @@ export const createAutocompletePlugin = (options, api) => {
1009
1389
  }
1010
1390
  return accepted;
1011
1391
  },
1012
- focus: () => {
1392
+ focus: view => {
1013
1393
  var _options$useLocalMode2, _options$useLocalMode3;
1014
1394
  if (!isAutocompleteEnabled) {
1015
1395
  return false;
1016
1396
  }
1017
1397
  void ensureVocabularyReady();
1398
+ // First point at which the session is known to be using the input,
1399
+ // which is where the rest of the artifacts are requested too.
1400
+ void ensureInlineCodeHarvester().then(() => harvestDocument(view.state.doc, 'focus')).catch(error => {
1401
+ logException(error, {
1402
+ location: 'editor-plugin-autocomplete/harvestDocument'
1403
+ });
1404
+ });
1018
1405
  loadVectorsAsync({
1019
1406
  isLocalLLM: (_options$useLocalMode2 = options === null || options === void 0 ? void 0 : options.useLocalModel) !== null && _options$useLocalMode2 !== void 0 ? _options$useLocalMode2 : false,
1020
1407
  surface
@@ -1046,11 +1433,13 @@ export const createAutocompletePlugin = (options, api) => {
1046
1433
  // Capture up front so a subscription notification before the first PM
1047
1434
  // transaction can still drive slowLaneClient.updateContext (gated on currentView).
1048
1435
  currentView = editorView;
1436
+ mountedEditors.add(reprimeSessionLearning);
1049
1437
 
1050
1438
  // Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
1051
1439
  if (isAutocompleteEnabled && options !== null && options !== void 0 && options.subscribeToContextUpdates) {
1052
1440
  unsubscribeFromContextUpdates = options.subscribeToContextUpdates(() => {
1053
- // Bypass throttle for freshness; the in-flight guard prevents overlap.
1441
+ // Bypass throttle for freshness; a read still open defers this one
1442
+ // rather than overlapping it.
1054
1443
  refreshContext({
1055
1444
  source: 'subscription',
1056
1445
  allowThrottle: false
@@ -1076,16 +1465,21 @@ export const createAutocompletePlugin = (options, api) => {
1076
1465
  maybeUpdateSessionFrequency(view, prevState);
1077
1466
  const textBefore = getTextBeforeCursor(view.state);
1078
1467
  if (isWordBoundary(textBefore)) {
1079
- var _resolvedContext;
1468
+ var _resolvedContext5;
1080
1469
  slowLaneClient.updateContext(buildSlowLaneText(view.state.doc.textContent, resolvedContext));
1081
1470
 
1471
+ // The backtick input rule has fired by the time a span is
1472
+ // finished, so a word boundary is the earliest point the mark
1473
+ // exists to be found.
1474
+ harvestDocument(view.state.doc, 'word-boundary');
1475
+
1082
1476
  // Context may not have resolved on first focus (e.g. comment
1083
1477
  // thread still loading). Retry on word boundaries until we have
1084
1478
  // the parent comment, throttled so we don't refetch constantly
1085
1479
  // and capped so non-comment editors stop retrying entirely.
1086
1480
  // Skipped for push-channel hosts (subscribeToContextUpdates): they
1087
1481
  // never grow parentCommentContent, so polling only burns the budget.
1088
- if (!(options !== null && options !== void 0 && options.subscribeToContextUpdates) && !((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
1482
+ if (!(options !== null && options !== void 0 && options.subscribeToContextUpdates) && !((_resolvedContext5 = resolvedContext) !== null && _resolvedContext5 !== void 0 && _resolvedContext5.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
1089
1483
  // Only count the attempt when a fetch actually started, so an
1090
1484
  // in-flight or throttled no-op doesn't burn the retry budget.
1091
1485
  if (refreshContext({
@@ -1112,8 +1506,35 @@ export const createAutocompletePlugin = (options, api) => {
1112
1506
  if (hasDestroy(slowLaneClient)) {
1113
1507
  slowLaneClient.destroy();
1114
1508
  }
1509
+ queuedContextRefreshSource = undefined;
1115
1510
  pendingSessionContextTexts.clear();
1116
1511
  sessionIngestedContextTexts.clear();
1512
+ pendingHarvestTexts.clear();
1513
+ harvestedContextTexts.clear();
1514
+ // The harvested set holds one conversation's user and assistant
1515
+ // content, and the L1 boosts describe what that conversation was
1516
+ // about. Both live at module scope, so they have to be emptied here
1517
+ // or they leak into whichever editor mounts next — but only once no
1518
+ // editor is left to be using them. The scope key goes with them, and
1519
+ // only with them: clearing it while another editor is still mounted
1520
+ // would leave that editor's next read looking like a first one, with
1521
+ // nothing to compare against and everything still loaded.
1522
+ mountedEditors.delete(reprimeSessionLearning);
1523
+ if (mountedEditors.size === 0) {
1524
+ var _loadedInlineCodeHarv2;
1525
+ (_loadedInlineCodeHarv2 = loadedInlineCodeHarvester) === null || _loadedInlineCodeHarv2 === void 0 ? void 0 : _loadedInlineCodeHarv2.resetInlineCodeHarvest();
1526
+ // Only for a host that scopes its context, which is the one asking
1527
+ // for its learning to end with the conversation. A host that sends
1528
+ // no scope has boosts belonging to the page it is on, and that page
1529
+ // outlives an editor being unmounted — a Confluence comment box is
1530
+ // closed far more often than the page under it changes.
1531
+ if (contextScopeKey !== undefined) {
1532
+ resetSessionBoosts();
1533
+ contextScopeKey = undefined;
1534
+ }
1535
+ }
1536
+ inlineCodeHarvester = null;
1537
+ inlineCodeHarvesterPromise = undefined;
1117
1538
  }
1118
1539
  };
1119
1540
  }