@atlaskit/editor-plugin-autocomplete 4.0.3 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @atlaskit/editor-plugin-autocomplete
2
2
 
3
+ ## 4.0.5
4
+
5
+ ### Patch Changes
6
+
7
+ - [`73743eb8b36e6`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/73743eb8b36e6) -
8
+ CLeanup prefer static regex violations
9
+ - Updated dependencies
10
+
11
+ ## 4.0.4
12
+
13
+ ### Patch Changes
14
+
15
+ - [`ac12ed0b41ef4`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/ac12ed0b41ef4) -
16
+ Refactor contextual autocomplete wiring so the conversation-store-aware ChatInput owns the
17
+ autocomplete context bridge (getContext + subscribeToContextUpdates) via a new
18
+ useAutocompleteEditorContext hook, and the shared RovoChatPromptInput simply forwards a single
19
+ `autocomplete` prop to the editor preset. Removes the duplicated keying / listener-set plumbing
20
+ from the reusable input.
21
+
22
+ Adds an optional `subscribeToContextUpdates` option to the autocomplete plugin so host surfaces
23
+ that stream context after mount (e.g. Rovo chat messages) can push refreshes, complementing the
24
+ existing word-boundary retry that only covers a one-time, still-loading comment thread.
25
+
3
26
  ## 4.0.3
4
27
 
5
28
  ### Patch Changes
@@ -38,6 +38,11 @@ var MAX_INGESTED_CONTEXT_TEXTS = 50;
38
38
  // non-comment editors (where parentCommentContent never arrives) don't refetch
39
39
  // on every word boundary for the plugin's lifetime.
40
40
  var MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
41
+
42
+ // eslint-disable-next-line require-unicode-regexp
43
+ var TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
44
+ // eslint-disable-next-line require-unicode-regexp
45
+ var WORD_BOUNDARY_CHARS_REGEX = /[\s.,;:!?]/;
41
46
  var hasDestroy = function hasDestroy(client) {
42
47
  return 'destroy' in client && typeof client.destroy === 'function';
43
48
  };
@@ -225,9 +230,7 @@ var getTypedLengthForPrediction = function getTypedLengthForPrediction(textBefor
225
230
  if ((0, _slowLaneClient.isWordBoundary)(textBefore)) {
226
231
  return 0;
227
232
  }
228
-
229
- // eslint-disable-next-line require-unicode-regexp
230
- var trailingToken = (_textBefore$match$ = (_textBefore$match = textBefore.match(/([^\s.,;:!?]*)$/)) === null || _textBefore$match === void 0 ? void 0 : _textBefore$match[1]) !== null && _textBefore$match$ !== void 0 ? _textBefore$match$ : '';
233
+ var trailingToken = (_textBefore$match$ = (_textBefore$match = textBefore.match(TRAILING_NON_BOUNDARY_TOKEN_REGEX)) === null || _textBefore$match === void 0 ? void 0 : _textBefore$match[1]) !== null && _textBefore$match$ !== void 0 ? _textBefore$match$ : '';
231
234
  return trailingToken.length;
232
235
  };
233
236
  var isEnglishLocale = function isEnglishLocale(locale) {
@@ -249,6 +252,7 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
249
252
  * user has already typed several words before the promise resolved.
250
253
  */
251
254
  var currentView = null;
255
+ var unsubscribeFromContextUpdates;
252
256
  /**
253
257
  * Set after accepting a suggestion so the next doc-change update
254
258
  * skips scheduling a new prediction for the just-inserted text.
@@ -538,16 +542,14 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
538
542
  return;
539
543
  }
540
544
  var lastChar = newText[newText.length - 1];
541
- // eslint-disable-next-line require-unicode-regexp
542
- if (!/[\s.,;:!?]/.test(lastChar)) {
545
+ if (!WORD_BOUNDARY_CHARS_REGEX.test(lastChar)) {
543
546
  return;
544
547
  }
545
548
 
546
549
  // Only fire if the previous state did not already end on a boundary,
547
550
  // so we don't double-count when multiple boundary chars are inserted.
548
551
  var prevLastChar = prevText[prevText.length - 1];
549
- // eslint-disable-next-line require-unicode-regexp
550
- if (prevLastChar && /[\s.,;:!?]/.test(prevLastChar)) {
552
+ if (prevLastChar && WORD_BOUNDARY_CHARS_REGEX.test(prevLastChar)) {
551
553
  return;
552
554
  }
553
555
  var beforeBoundary = newText.slice(0, -1).trimEnd();
@@ -670,7 +672,21 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
670
672
  }
671
673
  }
672
674
  },
673
- view: function view() {
675
+ view: function view(editorView) {
676
+ // Capture up front so a subscription notification before the first PM
677
+ // transaction can still drive slowLaneClient.updateContext (gated on currentView).
678
+ currentView = editorView;
679
+
680
+ // Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
681
+ if (isAutocompleteEnabled && options !== null && options !== void 0 && options.subscribeToContextUpdates) {
682
+ unsubscribeFromContextUpdates = options.subscribeToContextUpdates(function () {
683
+ // Bypass throttle for freshness; the in-flight guard prevents overlap.
684
+ refreshContext({
685
+ source: 'subscription',
686
+ allowThrottle: false
687
+ });
688
+ });
689
+ }
674
690
  return {
675
691
  update: function update(view, prevState) {
676
692
  if (!isAutocompleteEnabled) {
@@ -699,7 +715,9 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
699
715
  // thread still loading). Retry on word boundaries until we have
700
716
  // the parent comment, throttled so we don't refetch constantly
701
717
  // and capped so non-comment editors stop retrying entirely.
702
- if (!((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
718
+ // Skipped for push-channel hosts (subscribeToContextUpdates): they
719
+ // never grow parentCommentContent, so polling only burns the budget.
720
+ if (!(options !== null && options !== void 0 && options.subscribeToContextUpdates) && !((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
703
721
  // Only count the attempt when a fetch actually started, so an
704
722
  // in-flight or throttled no-op doesn't burn the retry budget.
705
723
  if (refreshContext({
@@ -713,8 +731,11 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
713
731
  }
714
732
  },
715
733
  destroy: function destroy() {
734
+ var _unsubscribeFromConte;
716
735
  destroyed = true;
717
736
  currentView = null;
737
+ (_unsubscribeFromConte = unsubscribeFromContextUpdates) === null || _unsubscribeFromConte === void 0 || _unsubscribeFromConte();
738
+ unsubscribeFromContextUpdates = undefined;
718
739
  if (debounceTimer) {
719
740
  clearTimeout(debounceTimer);
720
741
  }
@@ -78,6 +78,8 @@ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length)
78
78
  // ─── Constants ───────────────────────────────────────────────────────────────
79
79
 
80
80
  var DEFAULT_DEBOUNCE_MS = 300;
81
+ // eslint-disable-next-line require-unicode-regexp
82
+ var OOM_REGEX = /\boom\b/;
81
83
  var LOCAL_MLC_CAUSAL_MODEL_ID = exports.LOCAL_MLC_CAUSAL_MODEL_ID = 'SmolLM2-135M-Instruct-q0f16-MLC';
82
84
 
83
85
  /**
@@ -264,7 +266,7 @@ var bePayloadDataPromise;
264
266
  * :param shape: `'object'` if the source JSON is `{...}`, `'array'` if `[...]`.
265
267
  * :returns: The parsed JSON value, or `null` if neither interop mode applies.
266
268
  */
267
- var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
269
+ function unwrapJsonModule(mod, shape) {
268
270
  if (mod == null || (0, _typeof2.default)(mod) !== 'object') {
269
271
  return null;
270
272
  }
@@ -316,7 +318,7 @@ var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
316
318
  return namespace.default;
317
319
  }
318
320
  return null;
319
- };
321
+ }
320
322
 
321
323
  /**
322
324
  * Lazily load and build the BE-parity lookup tables from their JSON payloads.
@@ -766,9 +768,7 @@ var createLocalSlowLaneClient = exports.createLocalSlowLaneClient = function cre
766
768
  if (lower.includes('loading chunk') || lower.includes('dynamically imported module') || lower.includes('dynamic import')) {
767
769
  return 'module_load_failed';
768
770
  }
769
- if (lower.includes('out of memory') ||
770
- // eslint-disable-next-line require-unicode-regexp
771
- /\boom\b/.test(lower) || lower.includes('allocation') || lower.includes('exceeds') || lower.includes('buffer size') || lower.includes('not enough memory')) {
771
+ if (lower.includes('out of memory') || OOM_REGEX.test(lower) || lower.includes('allocation') || lower.includes('exceeds') || lower.includes('buffer size') || lower.includes('not enough memory')) {
772
772
  return 'insufficient_memory';
773
773
  }
774
774
  // Pre-flight already returns missing_shader_f16 when the feature is absent,
@@ -964,6 +964,12 @@ var createLocalSlowLaneClient = exports.createLocalSlowLaneClient = function cre
964
964
  return _regenerator.default.wrap(function (_context4) {
965
965
  while (1) switch (_context4.prev = _context4.next) {
966
966
  case 0:
967
+ captureCompletionTime = function _captureCompletionTim(promise, onResolved) {
968
+ return promise.then(function (value) {
969
+ onResolved(performance.now());
970
+ return value;
971
+ });
972
+ };
967
973
  if (!(!engine || destroyed)) {
968
974
  _context4.next = 1;
969
975
  break;
@@ -983,12 +989,6 @@ var createLocalSlowLaneClient = exports.createLocalSlowLaneClient = function cre
983
989
  lmText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_TOKENS);
984
990
  semanticText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_WORDS);
985
991
  arcticInput = wrapForArctic(semanticText);
986
- captureCompletionTime = function captureCompletionTime(promise, onResolved) {
987
- return promise.then(function (value) {
988
- onResolved(performance.now());
989
- return value;
990
- });
991
- };
992
992
  if ((0, _debugMode.isAutocompleteDebugEnabled)()) {
993
993
  // eslint-disable-next-line no-console
994
994
  console.log("%c[LocalSlowLane] %c\uD83D\uDD22 Arctic input (".concat(arcticInput.length, " chars, ").concat(splitOnWhitespace(semanticText).length, " words): \"").concat(arcticInput.length > 100 ? "".concat(arcticInput.slice(0, 100), "\u2026") : arcticInput, "\""), 'color: #9c27b0; font-weight: bold;', 'color: #009688;');
@@ -29,6 +29,8 @@ var _debugMode = require("./debug-mode");
29
29
 
30
30
  // eslint-disable-next-line require-unicode-regexp
31
31
  var WORD_BOUNDARY_CHARS = /[\s.,;:!?]/;
32
+ // eslint-disable-next-line require-unicode-regexp
33
+ var TRAILING_SLASH_REGEX = /\/$/;
32
34
  var DEFAULT_DEBOUNCE_MS = 300;
33
35
 
34
36
  /**
@@ -80,8 +82,7 @@ var createSlowLaneClient = exports.createSlowLaneClient = function createSlowLan
80
82
  }
81
83
  return _context.abrupt("return");
82
84
  case 1:
83
- // eslint-disable-next-line require-unicode-regexp
84
- url = "".concat(baseUrl.replace(/\/$/, '')).concat(endpoint);
85
+ url = "".concat(baseUrl.replace(TRAILING_SLASH_REGEX, '')).concat(endpoint);
85
86
  payload = {
86
87
  text: text,
87
88
  session_id: sessionId
@@ -42,6 +42,12 @@ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length)
42
42
 
43
43
  // eslint-disable-next-line require-unicode-regexp
44
44
  var PUNCTUATION_BOUNDARY_REGEX = /^[.,;:!?()\[\]{}"'`]+|[.,;:!?()\[\]{}"'`]+$/g;
45
+ // eslint-disable-next-line require-unicode-regexp
46
+ var WHITESPACE_SPLIT_REGEX = /\s+/;
47
+ // eslint-disable-next-line require-unicode-regexp
48
+ var SENTENCE_BOUNDARY_REGEX = /[\n.?!]+/;
49
+ // eslint-disable-next-line require-unicode-regexp
50
+ var TRAILING_WHITESPACE_REGEX = /\s$/;
45
51
  var MIN_PREFIX_LENGTH = 3;
46
52
  var MAX_CANDIDATES = 200;
47
53
  var CONTEXT_WORDS = 10;
@@ -312,8 +318,8 @@ var getContextVectorForScoring = function getContextVectorForScoring(textBefore)
312
318
  };
313
319
  var tokenize = function tokenize(text) {
314
320
  var tokens = [];
315
- // eslint-disable-next-line require-unicode-regexp, @atlassian/perf-linting/no-expensive-split-replace
316
- var _iterator7 = _createForOfIteratorHelper(text.toLowerCase().split(/\s+/)),
321
+ // eslint-disable-next-line @atlassian/perf-linting/no-expensive-split-replace
322
+ var _iterator7 = _createForOfIteratorHelper(text.toLowerCase().split(WHITESPACE_SPLIT_REGEX)),
317
323
  _step7;
318
324
  try {
319
325
  for (_iterator7.s(); !(_step7 = _iterator7.n()).done;) {
@@ -333,12 +339,9 @@ var tokenize = function tokenize(text) {
333
339
  };
334
340
  var extractPreviousWord = function extractPreviousWord(text) {
335
341
  // Only consider the current sentence/line the user is typing in.
336
- // eslint-disable-next-line require-unicode-regexp
337
- var sentences = text.split(/[\n.?!]+/);
342
+ var sentences = text.split(SENTENCE_BOUNDARY_REGEX);
338
343
  var currentSentence = sentences[sentences.length - 1];
339
-
340
- // eslint-disable-next-line require-unicode-regexp
341
- var words = currentSentence.trimEnd().split(/\s+/);
344
+ var words = currentSentence.trimEnd().split(WHITESPACE_SPLIT_REGEX);
342
345
  return words.length >= 2 ? words[words.length - 2] : '';
343
346
  };
344
347
 
@@ -477,8 +480,7 @@ var predict = exports.predict = function predict(textBefore) {
477
480
  // }
478
481
 
479
482
  // ── Step 2: Prefix completion (≥3 chars typed) ──────────────────────────
480
- // eslint-disable-next-line require-unicode-regexp
481
- if (textBefore.length > 0 && /\s$/.test(textBefore)) {
483
+ if (textBefore.length > 0 && TRAILING_WHITESPACE_REGEX.test(textBefore)) {
482
484
  return null;
483
485
  }
484
486
  var trimmed = textBefore.trimEnd();
@@ -694,7 +696,7 @@ var predict = exports.predict = function predict(textBefore) {
694
696
  * array and a sparse numeric-keyed object are emitted identically as named
695
697
  * exports. Kept in lock-step with the matching helper in local-slow-lane-client.ts.
696
698
  */
697
- var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
699
+ function unwrapJsonModule(mod, shape) {
698
700
  if (mod == null || (0, _typeof2.default)(mod) !== 'object') {
699
701
  return null;
700
702
  }
@@ -730,7 +732,7 @@ var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
730
732
  return namespace.default;
731
733
  }
732
734
  return null;
733
- };
735
+ }
734
736
  var loadVectorsAsync = exports.loadVectorsAsync = /*#__PURE__*/function () {
735
737
  var _ref6 = (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee(options) {
736
738
  var _options$isLocalLLM;
@@ -24,6 +24,11 @@ const MAX_INGESTED_CONTEXT_TEXTS = 50;
24
24
  // non-comment editors (where parentCommentContent never arrives) don't refetch
25
25
  // on every word boundary for the plugin's lifetime.
26
26
  const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
27
+
28
+ // eslint-disable-next-line require-unicode-regexp
29
+ const TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
30
+ // eslint-disable-next-line require-unicode-regexp
31
+ const WORD_BOUNDARY_CHARS_REGEX = /[\s.,;:!?]/;
27
32
  const hasDestroy = client => 'destroy' in client && typeof client.destroy === 'function';
28
33
  const createInitialState = () => ({
29
34
  ghostText: '',
@@ -213,9 +218,7 @@ const getTypedLengthForPrediction = textBefore => {
213
218
  if (isWordBoundary(textBefore)) {
214
219
  return 0;
215
220
  }
216
-
217
- // eslint-disable-next-line require-unicode-regexp
218
- const trailingToken = (_textBefore$match$ = (_textBefore$match = textBefore.match(/([^\s.,;:!?]*)$/)) === null || _textBefore$match === void 0 ? void 0 : _textBefore$match[1]) !== null && _textBefore$match$ !== void 0 ? _textBefore$match$ : '';
221
+ const trailingToken = (_textBefore$match$ = (_textBefore$match = textBefore.match(TRAILING_NON_BOUNDARY_TOKEN_REGEX)) === null || _textBefore$match === void 0 ? void 0 : _textBefore$match[1]) !== null && _textBefore$match$ !== void 0 ? _textBefore$match$ : '';
219
222
  return trailingToken.length;
220
223
  };
221
224
  const isEnglishLocale = locale => {
@@ -237,6 +240,7 @@ export const createAutocompletePlugin = (options, api) => {
237
240
  * user has already typed several words before the promise resolved.
238
241
  */
239
242
  let currentView = null;
243
+ let unsubscribeFromContextUpdates;
240
244
  /**
241
245
  * Set after accepting a suggestion so the next doc-change update
242
246
  * skips scheduling a new prediction for the just-inserted text.
@@ -522,16 +526,14 @@ export const createAutocompletePlugin = (options, api) => {
522
526
  return;
523
527
  }
524
528
  const lastChar = newText[newText.length - 1];
525
- // eslint-disable-next-line require-unicode-regexp
526
- if (!/[\s.,;:!?]/.test(lastChar)) {
529
+ if (!WORD_BOUNDARY_CHARS_REGEX.test(lastChar)) {
527
530
  return;
528
531
  }
529
532
 
530
533
  // Only fire if the previous state did not already end on a boundary,
531
534
  // so we don't double-count when multiple boundary chars are inserted.
532
535
  const prevLastChar = prevText[prevText.length - 1];
533
- // eslint-disable-next-line require-unicode-regexp
534
- if (prevLastChar && /[\s.,;:!?]/.test(prevLastChar)) {
536
+ if (prevLastChar && WORD_BOUNDARY_CHARS_REGEX.test(prevLastChar)) {
535
537
  return;
536
538
  }
537
539
  const beforeBoundary = newText.slice(0, -1).trimEnd();
@@ -657,59 +659,80 @@ export const createAutocompletePlugin = (options, api) => {
657
659
  }
658
660
  }
659
661
  },
660
- view: () => ({
661
- update: (view, prevState) => {
662
- if (!isAutocompleteEnabled) {
663
- return;
664
- }
665
- currentView = view;
666
- if (!prevState.doc.eq(view.state.doc)) {
667
- if (justAccepted) {
668
- justAccepted = false;
662
+ view: editorView => {
663
+ // Capture up front so a subscription notification before the first PM
664
+ // transaction can still drive slowLaneClient.updateContext (gated on currentView).
665
+ currentView = editorView;
669
666
 
670
- // Snapshot the post-acceptance text so follow-up transactions hit
671
- // the dismissedContext guard and abort until the user types again.
672
- dismissedContext = getTextBeforeCursor(view.state);
673
- if (debounceTimer) {
674
- clearTimeout(debounceTimer);
675
- }
667
+ // Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
668
+ if (isAutocompleteEnabled && options !== null && options !== void 0 && options.subscribeToContextUpdates) {
669
+ unsubscribeFromContextUpdates = options.subscribeToContextUpdates(() => {
670
+ // Bypass throttle for freshness; the in-flight guard prevents overlap.
671
+ refreshContext({
672
+ source: 'subscription',
673
+ allowThrottle: false
674
+ });
675
+ });
676
+ }
677
+ return {
678
+ update: (view, prevState) => {
679
+ if (!isAutocompleteEnabled) {
676
680
  return;
677
681
  }
678
- maybeUpdateSessionFrequency(view, prevState);
679
- const textBefore = getTextBeforeCursor(view.state);
680
- if (isWordBoundary(textBefore)) {
681
- var _resolvedContext;
682
- slowLaneClient.updateContext(buildSlowLaneText(view.state.doc.textContent, resolvedContext));
682
+ currentView = view;
683
+ if (!prevState.doc.eq(view.state.doc)) {
684
+ if (justAccepted) {
685
+ justAccepted = false;
686
+
687
+ // Snapshot the post-acceptance text so follow-up transactions hit
688
+ // the dismissedContext guard and abort until the user types again.
689
+ dismissedContext = getTextBeforeCursor(view.state);
690
+ if (debounceTimer) {
691
+ clearTimeout(debounceTimer);
692
+ }
693
+ return;
694
+ }
695
+ maybeUpdateSessionFrequency(view, prevState);
696
+ const textBefore = getTextBeforeCursor(view.state);
697
+ if (isWordBoundary(textBefore)) {
698
+ var _resolvedContext;
699
+ slowLaneClient.updateContext(buildSlowLaneText(view.state.doc.textContent, resolvedContext));
683
700
 
684
- // Context may not have resolved on first focus (e.g. comment
685
- // thread still loading). Retry on word boundaries until we have
686
- // the parent comment, throttled so we don't refetch constantly
687
- // and capped so non-comment editors stop retrying entirely.
688
- if (!((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
689
- // Only count the attempt when a fetch actually started, so an
690
- // in-flight or throttled no-op doesn't burn the retry budget.
691
- if (refreshContext({
692
- source: 'word-boundary'
693
- })) {
694
- wordBoundaryRefreshAttempts++;
701
+ // Context may not have resolved on first focus (e.g. comment
702
+ // thread still loading). Retry on word boundaries until we have
703
+ // the parent comment, throttled so we don't refetch constantly
704
+ // and capped so non-comment editors stop retrying entirely.
705
+ // Skipped for push-channel hosts (subscribeToContextUpdates): they
706
+ // never grow parentCommentContent, so polling only burns the budget.
707
+ if (!(options !== null && options !== void 0 && options.subscribeToContextUpdates) && !((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
708
+ // Only count the attempt when a fetch actually started, so an
709
+ // in-flight or throttled no-op doesn't burn the retry budget.
710
+ if (refreshContext({
711
+ source: 'word-boundary'
712
+ })) {
713
+ wordBoundaryRefreshAttempts++;
714
+ }
695
715
  }
696
716
  }
717
+ schedulePrediction(view);
697
718
  }
698
- schedulePrediction(view);
699
- }
700
- },
701
- destroy: () => {
702
- destroyed = true;
703
- currentView = null;
704
- if (debounceTimer) {
705
- clearTimeout(debounceTimer);
706
- }
707
- if (hasDestroy(slowLaneClient)) {
708
- slowLaneClient.destroy();
719
+ },
720
+ destroy: () => {
721
+ var _unsubscribeFromConte;
722
+ destroyed = true;
723
+ currentView = null;
724
+ (_unsubscribeFromConte = unsubscribeFromContextUpdates) === null || _unsubscribeFromConte === void 0 ? void 0 : _unsubscribeFromConte();
725
+ unsubscribeFromContextUpdates = undefined;
726
+ if (debounceTimer) {
727
+ clearTimeout(debounceTimer);
728
+ }
729
+ if (hasDestroy(slowLaneClient)) {
730
+ slowLaneClient.destroy();
731
+ }
732
+ ingestedContextTexts.clear();
733
+ setDefaultSlowLaneClient(null);
709
734
  }
710
- ingestedContextTexts.clear();
711
- setDefaultSlowLaneClient(null);
712
- }
713
- })
735
+ };
736
+ }
714
737
  });
715
738
  };
@@ -60,6 +60,8 @@ import { isWordBoundary } from './slow-lane-client';
60
60
  // ─── Constants ───────────────────────────────────────────────────────────────
61
61
 
62
62
  const DEFAULT_DEBOUNCE_MS = 300;
63
+ // eslint-disable-next-line require-unicode-regexp
64
+ const OOM_REGEX = /\boom\b/;
63
65
  export const LOCAL_MLC_CAUSAL_MODEL_ID = 'SmolLM2-135M-Instruct-q0f16-MLC';
64
66
 
65
67
  /**
@@ -247,7 +249,7 @@ let bePayloadDataPromise;
247
249
  * :param shape: `'object'` if the source JSON is `{...}`, `'array'` if `[...]`.
248
250
  * :returns: The parsed JSON value, or `null` if neither interop mode applies.
249
251
  */
250
- const unwrapJsonModule = (mod, shape) => {
252
+ function unwrapJsonModule(mod, shape) {
251
253
  if (mod == null || typeof mod !== 'object') {
252
254
  return null;
253
255
  }
@@ -288,7 +290,7 @@ const unwrapJsonModule = (mod, shape) => {
288
290
  return namespace.default;
289
291
  }
290
292
  return null;
291
- };
293
+ }
292
294
 
293
295
  /**
294
296
  * Lazily load and build the BE-parity lookup tables from their JSON payloads.
@@ -587,9 +589,7 @@ export const createLocalSlowLaneClient = (config = {}) => {
587
589
  if (lower.includes('loading chunk') || lower.includes('dynamically imported module') || lower.includes('dynamic import')) {
588
590
  return 'module_load_failed';
589
591
  }
590
- if (lower.includes('out of memory') ||
591
- // eslint-disable-next-line require-unicode-regexp
592
- /\boom\b/.test(lower) || lower.includes('allocation') || lower.includes('exceeds') || lower.includes('buffer size') || lower.includes('not enough memory')) {
592
+ if (lower.includes('out of memory') || OOM_REGEX.test(lower) || lower.includes('allocation') || lower.includes('exceeds') || lower.includes('buffer size') || lower.includes('not enough memory')) {
593
593
  return 'insufficient_memory';
594
594
  }
595
595
  // Pre-flight already returns missing_shader_f16 when the feature is absent,
@@ -764,10 +764,12 @@ export const createLocalSlowLaneClient = (config = {}) => {
764
764
  const lmText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_TOKENS);
765
765
  const semanticText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_WORDS);
766
766
  const arcticInput = wrapForArctic(semanticText);
767
- const captureCompletionTime = (promise, onResolved) => promise.then(value => {
768
- onResolved(performance.now());
769
- return value;
770
- });
767
+ function captureCompletionTime(promise, onResolved) {
768
+ return promise.then(value => {
769
+ onResolved(performance.now());
770
+ return value;
771
+ });
772
+ }
771
773
  if (isAutocompleteDebugEnabled()) {
772
774
  // eslint-disable-next-line no-console
773
775
  console.log(`%c[LocalSlowLane] %c🔢 Arctic input (${arcticInput.length} chars, ${splitOnWhitespace(semanticText).length} words): "${arcticInput.length > 100 ? `${arcticInput.slice(0, 100)}…` : arcticInput}"`, 'color: #9c27b0; font-weight: bold;', 'color: #009688;');
@@ -20,6 +20,8 @@ import { isAutocompleteDebugEnabled } from './debug-mode';
20
20
 
21
21
  // eslint-disable-next-line require-unicode-regexp
22
22
  const WORD_BOUNDARY_CHARS = /[\s.,;:!?]/;
23
+ // eslint-disable-next-line require-unicode-regexp
24
+ const TRAILING_SLASH_REGEX = /\/$/;
23
25
  const DEFAULT_DEBOUNCE_MS = 300;
24
26
 
25
27
  /**
@@ -61,9 +63,7 @@ export const createSlowLaneClient = config => {
61
63
  if (!text || text.trim().length === 0) {
62
64
  return;
63
65
  }
64
-
65
- // eslint-disable-next-line require-unicode-regexp
66
- const url = `${baseUrl.replace(/\/$/, '')}${endpoint}`;
66
+ const url = `${baseUrl.replace(TRAILING_SLASH_REGEX, '')}${endpoint}`;
67
67
  const payload = {
68
68
  text,
69
69
  session_id: sessionId
@@ -29,6 +29,12 @@ import { getStoredContextVector, getStoredLmLogits } from './slow-lane-client';
29
29
 
30
30
  // eslint-disable-next-line require-unicode-regexp
31
31
  const PUNCTUATION_BOUNDARY_REGEX = /^[.,;:!?()\[\]{}"'`]+|[.,;:!?()\[\]{}"'`]+$/g;
32
+ // eslint-disable-next-line require-unicode-regexp
33
+ const WHITESPACE_SPLIT_REGEX = /\s+/;
34
+ // eslint-disable-next-line require-unicode-regexp
35
+ const SENTENCE_BOUNDARY_REGEX = /[\n.?!]+/;
36
+ // eslint-disable-next-line require-unicode-regexp
37
+ const TRAILING_WHITESPACE_REGEX = /\s$/;
32
38
  const MIN_PREFIX_LENGTH = 3;
33
39
  const MAX_CANDIDATES = 200;
34
40
  const CONTEXT_WORDS = 10;
@@ -235,8 +241,8 @@ const getContextVectorForScoring = textBefore => {
235
241
  };
236
242
  const tokenize = text => {
237
243
  const tokens = [];
238
- // eslint-disable-next-line require-unicode-regexp, @atlassian/perf-linting/no-expensive-split-replace
239
- for (const raw of text.toLowerCase().split(/\s+/)) {
244
+ // eslint-disable-next-line @atlassian/perf-linting/no-expensive-split-replace
245
+ for (const raw of text.toLowerCase().split(WHITESPACE_SPLIT_REGEX)) {
240
246
  // eslint-disable-next-line @atlassian/perf-linting/no-expensive-split-replace
241
247
  const clean = raw.replace(PUNCTUATION_BOUNDARY_REGEX, '');
242
248
  if (clean.length >= 2) {
@@ -247,12 +253,9 @@ const tokenize = text => {
247
253
  };
248
254
  const extractPreviousWord = text => {
249
255
  // Only consider the current sentence/line the user is typing in.
250
- // eslint-disable-next-line require-unicode-regexp
251
- const sentences = text.split(/[\n.?!]+/);
256
+ const sentences = text.split(SENTENCE_BOUNDARY_REGEX);
252
257
  const currentSentence = sentences[sentences.length - 1];
253
-
254
- // eslint-disable-next-line require-unicode-regexp
255
- const words = currentSentence.trimEnd().split(/\s+/);
258
+ const words = currentSentence.trimEnd().split(WHITESPACE_SPLIT_REGEX);
256
259
  return words.length >= 2 ? words[words.length - 2] : '';
257
260
  };
258
261
 
@@ -373,8 +376,7 @@ export const predict = textBefore => {
373
376
  // }
374
377
 
375
378
  // ── Step 2: Prefix completion (≥3 chars typed) ──────────────────────────
376
- // eslint-disable-next-line require-unicode-regexp
377
- if (textBefore.length > 0 && /\s$/.test(textBefore)) {
379
+ if (textBefore.length > 0 && TRAILING_WHITESPACE_REGEX.test(textBefore)) {
378
380
  return null;
379
381
  }
380
382
  const trimmed = textBefore.trimEnd();
@@ -560,7 +562,7 @@ export const predict = textBefore => {
560
562
  * array and a sparse numeric-keyed object are emitted identically as named
561
563
  * exports. Kept in lock-step with the matching helper in local-slow-lane-client.ts.
562
564
  */
563
- const unwrapJsonModule = (mod, shape) => {
565
+ function unwrapJsonModule(mod, shape) {
564
566
  if (mod == null || typeof mod !== 'object') {
565
567
  return null;
566
568
  }
@@ -585,7 +587,7 @@ const unwrapJsonModule = (mod, shape) => {
585
587
  return namespace.default;
586
588
  }
587
589
  return null;
588
- };
590
+ }
589
591
  export const loadVectorsAsync = async options => {
590
592
  var _options$isLocalLLM;
591
593
  if (vectorStore || vectorsLoadStarted) {
@@ -31,6 +31,11 @@ var MAX_INGESTED_CONTEXT_TEXTS = 50;
31
31
  // non-comment editors (where parentCommentContent never arrives) don't refetch
32
32
  // on every word boundary for the plugin's lifetime.
33
33
  var MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
34
+
35
+ // eslint-disable-next-line require-unicode-regexp
36
+ var TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
37
+ // eslint-disable-next-line require-unicode-regexp
38
+ var WORD_BOUNDARY_CHARS_REGEX = /[\s.,;:!?]/;
34
39
  var hasDestroy = function hasDestroy(client) {
35
40
  return 'destroy' in client && typeof client.destroy === 'function';
36
41
  };
@@ -218,9 +223,7 @@ var getTypedLengthForPrediction = function getTypedLengthForPrediction(textBefor
218
223
  if (isWordBoundary(textBefore)) {
219
224
  return 0;
220
225
  }
221
-
222
- // eslint-disable-next-line require-unicode-regexp
223
- var trailingToken = (_textBefore$match$ = (_textBefore$match = textBefore.match(/([^\s.,;:!?]*)$/)) === null || _textBefore$match === void 0 ? void 0 : _textBefore$match[1]) !== null && _textBefore$match$ !== void 0 ? _textBefore$match$ : '';
226
+ var trailingToken = (_textBefore$match$ = (_textBefore$match = textBefore.match(TRAILING_NON_BOUNDARY_TOKEN_REGEX)) === null || _textBefore$match === void 0 ? void 0 : _textBefore$match[1]) !== null && _textBefore$match$ !== void 0 ? _textBefore$match$ : '';
224
227
  return trailingToken.length;
225
228
  };
226
229
  var isEnglishLocale = function isEnglishLocale(locale) {
@@ -242,6 +245,7 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
242
245
  * user has already typed several words before the promise resolved.
243
246
  */
244
247
  var currentView = null;
248
+ var unsubscribeFromContextUpdates;
245
249
  /**
246
250
  * Set after accepting a suggestion so the next doc-change update
247
251
  * skips scheduling a new prediction for the just-inserted text.
@@ -531,16 +535,14 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
531
535
  return;
532
536
  }
533
537
  var lastChar = newText[newText.length - 1];
534
- // eslint-disable-next-line require-unicode-regexp
535
- if (!/[\s.,;:!?]/.test(lastChar)) {
538
+ if (!WORD_BOUNDARY_CHARS_REGEX.test(lastChar)) {
536
539
  return;
537
540
  }
538
541
 
539
542
  // Only fire if the previous state did not already end on a boundary,
540
543
  // so we don't double-count when multiple boundary chars are inserted.
541
544
  var prevLastChar = prevText[prevText.length - 1];
542
- // eslint-disable-next-line require-unicode-regexp
543
- if (prevLastChar && /[\s.,;:!?]/.test(prevLastChar)) {
545
+ if (prevLastChar && WORD_BOUNDARY_CHARS_REGEX.test(prevLastChar)) {
544
546
  return;
545
547
  }
546
548
  var beforeBoundary = newText.slice(0, -1).trimEnd();
@@ -663,7 +665,21 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
663
665
  }
664
666
  }
665
667
  },
666
- view: function view() {
668
+ view: function view(editorView) {
669
+ // Capture up front so a subscription notification before the first PM
670
+ // transaction can still drive slowLaneClient.updateContext (gated on currentView).
671
+ currentView = editorView;
672
+
673
+ // Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
674
+ if (isAutocompleteEnabled && options !== null && options !== void 0 && options.subscribeToContextUpdates) {
675
+ unsubscribeFromContextUpdates = options.subscribeToContextUpdates(function () {
676
+ // Bypass throttle for freshness; the in-flight guard prevents overlap.
677
+ refreshContext({
678
+ source: 'subscription',
679
+ allowThrottle: false
680
+ });
681
+ });
682
+ }
667
683
  return {
668
684
  update: function update(view, prevState) {
669
685
  if (!isAutocompleteEnabled) {
@@ -692,7 +708,9 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
692
708
  // thread still loading). Retry on word boundaries until we have
693
709
  // the parent comment, throttled so we don't refetch constantly
694
710
  // and capped so non-comment editors stop retrying entirely.
695
- if (!((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
711
+ // Skipped for push-channel hosts (subscribeToContextUpdates): they
712
+ // never grow parentCommentContent, so polling only burns the budget.
713
+ if (!(options !== null && options !== void 0 && options.subscribeToContextUpdates) && !((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
696
714
  // Only count the attempt when a fetch actually started, so an
697
715
  // in-flight or throttled no-op doesn't burn the retry budget.
698
716
  if (refreshContext({
@@ -706,8 +724,11 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
706
724
  }
707
725
  },
708
726
  destroy: function destroy() {
727
+ var _unsubscribeFromConte;
709
728
  destroyed = true;
710
729
  currentView = null;
730
+ (_unsubscribeFromConte = unsubscribeFromContextUpdates) === null || _unsubscribeFromConte === void 0 || _unsubscribeFromConte();
731
+ unsubscribeFromContextUpdates = undefined;
711
732
  if (debounceTimer) {
712
733
  clearTimeout(debounceTimer);
713
734
  }
@@ -72,6 +72,8 @@ import { isWordBoundary } from './slow-lane-client';
72
72
  // ─── Constants ───────────────────────────────────────────────────────────────
73
73
 
74
74
  var DEFAULT_DEBOUNCE_MS = 300;
75
+ // eslint-disable-next-line require-unicode-regexp
76
+ var OOM_REGEX = /\boom\b/;
75
77
  export var LOCAL_MLC_CAUSAL_MODEL_ID = 'SmolLM2-135M-Instruct-q0f16-MLC';
76
78
 
77
79
  /**
@@ -258,7 +260,7 @@ var bePayloadDataPromise;
258
260
  * :param shape: `'object'` if the source JSON is `{...}`, `'array'` if `[...]`.
259
261
  * :returns: The parsed JSON value, or `null` if neither interop mode applies.
260
262
  */
261
- var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
263
+ function unwrapJsonModule(mod, shape) {
262
264
  if (mod == null || _typeof(mod) !== 'object') {
263
265
  return null;
264
266
  }
@@ -310,7 +312,7 @@ var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
310
312
  return namespace.default;
311
313
  }
312
314
  return null;
313
- };
315
+ }
314
316
 
315
317
  /**
316
318
  * Lazily load and build the BE-parity lookup tables from their JSON payloads.
@@ -756,9 +758,7 @@ export var createLocalSlowLaneClient = function createLocalSlowLaneClient() {
756
758
  if (lower.includes('loading chunk') || lower.includes('dynamically imported module') || lower.includes('dynamic import')) {
757
759
  return 'module_load_failed';
758
760
  }
759
- if (lower.includes('out of memory') ||
760
- // eslint-disable-next-line require-unicode-regexp
761
- /\boom\b/.test(lower) || lower.includes('allocation') || lower.includes('exceeds') || lower.includes('buffer size') || lower.includes('not enough memory')) {
761
+ if (lower.includes('out of memory') || OOM_REGEX.test(lower) || lower.includes('allocation') || lower.includes('exceeds') || lower.includes('buffer size') || lower.includes('not enough memory')) {
762
762
  return 'insufficient_memory';
763
763
  }
764
764
  // Pre-flight already returns missing_shader_f16 when the feature is absent,
@@ -952,6 +952,12 @@ export var createLocalSlowLaneClient = function createLocalSlowLaneClient() {
952
952
  return _regeneratorRuntime.wrap(function (_context4) {
953
953
  while (1) switch (_context4.prev = _context4.next) {
954
954
  case 0:
955
+ captureCompletionTime = function _captureCompletionTim(promise, onResolved) {
956
+ return promise.then(function (value) {
957
+ onResolved(performance.now());
958
+ return value;
959
+ });
960
+ };
955
961
  if (!(!engine || destroyed)) {
956
962
  _context4.next = 1;
957
963
  break;
@@ -971,12 +977,6 @@ export var createLocalSlowLaneClient = function createLocalSlowLaneClient() {
971
977
  lmText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_TOKENS);
972
978
  semanticText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_WORDS);
973
979
  arcticInput = wrapForArctic(semanticText);
974
- captureCompletionTime = function captureCompletionTime(promise, onResolved) {
975
- return promise.then(function (value) {
976
- onResolved(performance.now());
977
- return value;
978
- });
979
- };
980
980
  if (isAutocompleteDebugEnabled()) {
981
981
  // eslint-disable-next-line no-console
982
982
  console.log("%c[LocalSlowLane] %c\uD83D\uDD22 Arctic input (".concat(arcticInput.length, " chars, ").concat(splitOnWhitespace(semanticText).length, " words): \"").concat(arcticInput.length > 100 ? "".concat(arcticInput.slice(0, 100), "\u2026") : arcticInput, "\""), 'color: #9c27b0; font-weight: bold;', 'color: #009688;');
@@ -23,6 +23,8 @@ import { isAutocompleteDebugEnabled } from './debug-mode';
23
23
 
24
24
  // eslint-disable-next-line require-unicode-regexp
25
25
  var WORD_BOUNDARY_CHARS = /[\s.,;:!?]/;
26
+ // eslint-disable-next-line require-unicode-regexp
27
+ var TRAILING_SLASH_REGEX = /\/$/;
26
28
  var DEFAULT_DEBOUNCE_MS = 300;
27
29
 
28
30
  /**
@@ -74,8 +76,7 @@ export var createSlowLaneClient = function createSlowLaneClient(config) {
74
76
  }
75
77
  return _context.abrupt("return");
76
78
  case 1:
77
- // eslint-disable-next-line require-unicode-regexp
78
- url = "".concat(baseUrl.replace(/\/$/, '')).concat(endpoint);
79
+ url = "".concat(baseUrl.replace(TRAILING_SLASH_REGEX, '')).concat(endpoint);
79
80
  payload = {
80
81
  text: text,
81
82
  session_id: sessionId
@@ -38,6 +38,12 @@ import { getStoredContextVector, getStoredLmLogits } from './slow-lane-client';
38
38
 
39
39
  // eslint-disable-next-line require-unicode-regexp
40
40
  var PUNCTUATION_BOUNDARY_REGEX = /^[.,;:!?()\[\]{}"'`]+|[.,;:!?()\[\]{}"'`]+$/g;
41
+ // eslint-disable-next-line require-unicode-regexp
42
+ var WHITESPACE_SPLIT_REGEX = /\s+/;
43
+ // eslint-disable-next-line require-unicode-regexp
44
+ var SENTENCE_BOUNDARY_REGEX = /[\n.?!]+/;
45
+ // eslint-disable-next-line require-unicode-regexp
46
+ var TRAILING_WHITESPACE_REGEX = /\s$/;
41
47
  var MIN_PREFIX_LENGTH = 3;
42
48
  var MAX_CANDIDATES = 200;
43
49
  var CONTEXT_WORDS = 10;
@@ -308,8 +314,8 @@ var getContextVectorForScoring = function getContextVectorForScoring(textBefore)
308
314
  };
309
315
  var tokenize = function tokenize(text) {
310
316
  var tokens = [];
311
- // eslint-disable-next-line require-unicode-regexp, @atlassian/perf-linting/no-expensive-split-replace
312
- var _iterator7 = _createForOfIteratorHelper(text.toLowerCase().split(/\s+/)),
317
+ // eslint-disable-next-line @atlassian/perf-linting/no-expensive-split-replace
318
+ var _iterator7 = _createForOfIteratorHelper(text.toLowerCase().split(WHITESPACE_SPLIT_REGEX)),
313
319
  _step7;
314
320
  try {
315
321
  for (_iterator7.s(); !(_step7 = _iterator7.n()).done;) {
@@ -329,12 +335,9 @@ var tokenize = function tokenize(text) {
329
335
  };
330
336
  var extractPreviousWord = function extractPreviousWord(text) {
331
337
  // Only consider the current sentence/line the user is typing in.
332
- // eslint-disable-next-line require-unicode-regexp
333
- var sentences = text.split(/[\n.?!]+/);
338
+ var sentences = text.split(SENTENCE_BOUNDARY_REGEX);
334
339
  var currentSentence = sentences[sentences.length - 1];
335
-
336
- // eslint-disable-next-line require-unicode-regexp
337
- var words = currentSentence.trimEnd().split(/\s+/);
340
+ var words = currentSentence.trimEnd().split(WHITESPACE_SPLIT_REGEX);
338
341
  return words.length >= 2 ? words[words.length - 2] : '';
339
342
  };
340
343
 
@@ -473,8 +476,7 @@ export var predict = function predict(textBefore) {
473
476
  // }
474
477
 
475
478
  // ── Step 2: Prefix completion (≥3 chars typed) ──────────────────────────
476
- // eslint-disable-next-line require-unicode-regexp
477
- if (textBefore.length > 0 && /\s$/.test(textBefore)) {
479
+ if (textBefore.length > 0 && TRAILING_WHITESPACE_REGEX.test(textBefore)) {
478
480
  return null;
479
481
  }
480
482
  var trimmed = textBefore.trimEnd();
@@ -690,7 +692,7 @@ export var predict = function predict(textBefore) {
690
692
  * array and a sparse numeric-keyed object are emitted identically as named
691
693
  * exports. Kept in lock-step with the matching helper in local-slow-lane-client.ts.
692
694
  */
693
- var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
695
+ function unwrapJsonModule(mod, shape) {
694
696
  if (mod == null || _typeof(mod) !== 'object') {
695
697
  return null;
696
698
  }
@@ -726,7 +728,7 @@ var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
726
728
  return namespace.default;
727
729
  }
728
730
  return null;
729
- };
731
+ }
730
732
  export var loadVectorsAsync = /*#__PURE__*/function () {
731
733
  var _ref6 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee(options) {
732
734
  var _options$isLocalLLM;
@@ -1,2 +1,3 @@
1
1
  export { autocompletePlugin } from './autocompletePlugin';
2
2
  export type { AutocompletePlugin } from './autocompletePluginType';
3
+ export type { AutocompleteContext, AutocompletePluginOptions, } from './pm-plugins/autocomplete-plugin';
@@ -40,6 +40,12 @@ export interface AutocompletePluginOptions {
40
40
  * Use this to serve vectors from a CDN or media service in production.
41
41
  */
42
42
  getVectorsBinaryUrl?: () => Promise<string>;
43
+ /**
44
+ * Subscription for hosts with live external context (e.g. Rovo chat). The plugin
45
+ * re-fetches via getContext() on each notification and unsubscribes on destroy.
46
+ * Only meaningful alongside `getContext`; without it each notification is a no-op.
47
+ */
48
+ subscribeToContextUpdates?: (onContextUpdated: () => void) => () => void;
43
49
  /**
44
50
  * User locale used to determine whether autocomplete should run.
45
51
  * Defaults to browser locale when omitted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-autocomplete",
3
- "version": "4.0.3",
3
+ "version": "4.0.5",
4
4
  "description": "Client-side text autocomplete plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -27,7 +27,7 @@
27
27
  "wink-nlp": "^2.4.0"
28
28
  },
29
29
  "peerDependencies": {
30
- "@atlaskit/editor-common": "^116.6.0",
30
+ "@atlaskit/editor-common": "^116.17.0",
31
31
  "@atlaskit/editor-plugin-analytics": "^12.0.0",
32
32
  "react": "^18.2.0"
33
33
  },
package/src/index.ts CHANGED
@@ -3,3 +3,7 @@
3
3
 
4
4
  export { autocompletePlugin } from './autocompletePlugin';
5
5
  export type { AutocompletePlugin } from './autocompletePluginType';
6
+ export type {
7
+ AutocompleteContext,
8
+ AutocompletePluginOptions,
9
+ } from './pm-plugins/autocomplete-plugin';
@@ -48,6 +48,11 @@ const MAX_INGESTED_CONTEXT_TEXTS = 50;
48
48
  // on every word boundary for the plugin's lifetime.
49
49
  const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
50
50
 
51
+ // eslint-disable-next-line require-unicode-regexp
52
+ const TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
53
+ // eslint-disable-next-line require-unicode-regexp
54
+ const WORD_BOUNDARY_CHARS_REGEX = /[\s.,;:!?]/;
55
+
51
56
  const hasDestroy = (
52
57
  client: ReturnType<typeof createSlowLaneClient> | LocalSlowLaneClient,
53
58
  ): client is LocalSlowLaneClient => 'destroy' in client && typeof client.destroy === 'function';
@@ -214,6 +219,12 @@ export interface AutocompletePluginOptions {
214
219
  * Use this to serve vectors from a CDN or media service in production.
215
220
  */
216
221
  getVectorsBinaryUrl?: () => Promise<string>;
222
+ /**
223
+ * Subscription for hosts with live external context (e.g. Rovo chat). The plugin
224
+ * re-fetches via getContext() on each notification and unsubscribes on destroy.
225
+ * Only meaningful alongside `getContext`; without it each notification is a no-op.
226
+ */
227
+ subscribeToContextUpdates?: (onContextUpdated: () => void) => () => void;
217
228
  /**
218
229
  * User locale used to determine whether autocomplete should run.
219
230
  * Defaults to browser locale when omitted.
@@ -299,8 +310,7 @@ const getTypedLengthForPrediction = (textBefore: string): number => {
299
310
  return 0;
300
311
  }
301
312
 
302
- // eslint-disable-next-line require-unicode-regexp
303
- const trailingToken = textBefore.match(/([^\s.,;:!?]*)$/)?.[1] ?? '';
313
+ const trailingToken = textBefore.match(TRAILING_NON_BOUNDARY_TOKEN_REGEX)?.[1] ?? '';
304
314
  return trailingToken.length;
305
315
  };
306
316
 
@@ -329,6 +339,7 @@ export const createAutocompletePlugin = (
329
339
  * user has already typed several words before the promise resolved.
330
340
  */
331
341
  let currentView: EditorView | null = null;
342
+ let unsubscribeFromContextUpdates: (() => void) | undefined;
332
343
  /**
333
344
  * Set after accepting a suggestion so the next doc-change update
334
345
  * skips scheduling a new prediction for the just-inserted text.
@@ -582,7 +593,9 @@ export const createAutocompletePlugin = (
582
593
  // nodeAfter correctly handles inline atoms (mentions, emojis) where
583
594
  // parentOffset and textContent indices diverge.
584
595
  const nodeAfter = selection.$from.nodeAfter;
585
- const charAfterCursor = nodeAfter?.isText ? getLeadingTextCharacter(nodeAfter.text) : undefined;
596
+ const charAfterCursor = nodeAfter?.isText
597
+ ? getLeadingTextCharacter(nodeAfter.text)
598
+ : undefined;
586
599
  // Only suppress for Unicode letters, digits, and underscore — hyphens,
587
600
  // apostrophes, and similar punctuation are valid left-edge boundaries
588
601
  // and should not block suggestions (e.g. cursor before '-' in compound-word).
@@ -638,16 +651,14 @@ export const createAutocompletePlugin = (
638
651
  }
639
652
 
640
653
  const lastChar = newText[newText.length - 1];
641
- // eslint-disable-next-line require-unicode-regexp
642
- if (!/[\s.,;:!?]/.test(lastChar)) {
654
+ if (!WORD_BOUNDARY_CHARS_REGEX.test(lastChar)) {
643
655
  return;
644
656
  }
645
657
 
646
658
  // Only fire if the previous state did not already end on a boundary,
647
659
  // so we don't double-count when multiple boundary chars are inserted.
648
660
  const prevLastChar = prevText[prevText.length - 1];
649
- // eslint-disable-next-line require-unicode-regexp
650
- if (prevLastChar && /[\s.,;:!?]/.test(prevLastChar)) {
661
+ if (prevLastChar && WORD_BOUNDARY_CHARS_REGEX.test(prevLastChar)) {
651
662
  return;
652
663
  }
653
664
 
@@ -790,66 +801,85 @@ export const createAutocompletePlugin = (
790
801
  },
791
802
  },
792
803
 
793
- view: () => ({
794
- update: (view: EditorView, prevState: EditorState) => {
795
- if (!isAutocompleteEnabled) {
796
- return;
797
- }
798
-
799
- currentView = view;
800
- if (!prevState.doc.eq(view.state.doc)) {
801
- if (justAccepted) {
802
- justAccepted = false;
804
+ view: (editorView: EditorView) => {
805
+ // Capture up front so a subscription notification before the first PM
806
+ // transaction can still drive slowLaneClient.updateContext (gated on currentView).
807
+ currentView = editorView;
803
808
 
804
- // Snapshot the post-acceptance text so follow-up transactions hit
805
- // the dismissedContext guard and abort until the user types again.
806
- dismissedContext = getTextBeforeCursor(view.state);
809
+ // Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
810
+ if (isAutocompleteEnabled && options?.subscribeToContextUpdates) {
811
+ unsubscribeFromContextUpdates = options.subscribeToContextUpdates(() => {
812
+ // Bypass throttle for freshness; the in-flight guard prevents overlap.
813
+ refreshContext({ source: 'subscription', allowThrottle: false });
814
+ });
815
+ }
807
816
 
808
- if (debounceTimer) {
809
- clearTimeout(debounceTimer);
810
- }
817
+ return {
818
+ update: (view: EditorView, prevState: EditorState) => {
819
+ if (!isAutocompleteEnabled) {
811
820
  return;
812
821
  }
813
822
 
814
- maybeUpdateSessionFrequency(view, prevState);
815
-
816
- const textBefore = getTextBeforeCursor(view.state);
817
- if (isWordBoundary(textBefore)) {
818
- slowLaneClient.updateContext(
819
- buildSlowLaneText(view.state.doc.textContent, resolvedContext),
820
- );
821
-
822
- // Context may not have resolved on first focus (e.g. comment
823
- // thread still loading). Retry on word boundaries until we have
824
- // the parent comment, throttled so we don't refetch constantly
825
- // and capped so non-comment editors stop retrying entirely.
826
- if (
827
- !resolvedContext?.parentCommentContent &&
828
- wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS
829
- ) {
830
- // Only count the attempt when a fetch actually started, so an
831
- // in-flight or throttled no-op doesn't burn the retry budget.
832
- if (refreshContext({ source: 'word-boundary' })) {
833
- wordBoundaryRefreshAttempts++;
823
+ currentView = view;
824
+ if (!prevState.doc.eq(view.state.doc)) {
825
+ if (justAccepted) {
826
+ justAccepted = false;
827
+
828
+ // Snapshot the post-acceptance text so follow-up transactions hit
829
+ // the dismissedContext guard and abort until the user types again.
830
+ dismissedContext = getTextBeforeCursor(view.state);
831
+
832
+ if (debounceTimer) {
833
+ clearTimeout(debounceTimer);
834
834
  }
835
+ return;
835
836
  }
836
- }
837
837
 
838
- schedulePrediction(view);
839
- }
840
- },
841
- destroy: () => {
842
- destroyed = true;
843
- currentView = null;
844
- if (debounceTimer) {
845
- clearTimeout(debounceTimer);
846
- }
847
- if (hasDestroy(slowLaneClient)) {
848
- slowLaneClient.destroy();
849
- }
850
- ingestedContextTexts.clear();
851
- setDefaultSlowLaneClient(null);
852
- },
853
- }),
838
+ maybeUpdateSessionFrequency(view, prevState);
839
+
840
+ const textBefore = getTextBeforeCursor(view.state);
841
+ if (isWordBoundary(textBefore)) {
842
+ slowLaneClient.updateContext(
843
+ buildSlowLaneText(view.state.doc.textContent, resolvedContext),
844
+ );
845
+
846
+ // Context may not have resolved on first focus (e.g. comment
847
+ // thread still loading). Retry on word boundaries until we have
848
+ // the parent comment, throttled so we don't refetch constantly
849
+ // and capped so non-comment editors stop retrying entirely.
850
+ // Skipped for push-channel hosts (subscribeToContextUpdates): they
851
+ // never grow parentCommentContent, so polling only burns the budget.
852
+ if (
853
+ !options?.subscribeToContextUpdates &&
854
+ !resolvedContext?.parentCommentContent &&
855
+ wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS
856
+ ) {
857
+ // Only count the attempt when a fetch actually started, so an
858
+ // in-flight or throttled no-op doesn't burn the retry budget.
859
+ if (refreshContext({ source: 'word-boundary' })) {
860
+ wordBoundaryRefreshAttempts++;
861
+ }
862
+ }
863
+ }
864
+
865
+ schedulePrediction(view);
866
+ }
867
+ },
868
+ destroy: () => {
869
+ destroyed = true;
870
+ currentView = null;
871
+ unsubscribeFromContextUpdates?.();
872
+ unsubscribeFromContextUpdates = undefined;
873
+ if (debounceTimer) {
874
+ clearTimeout(debounceTimer);
875
+ }
876
+ if (hasDestroy(slowLaneClient)) {
877
+ slowLaneClient.destroy();
878
+ }
879
+ ingestedContextTexts.clear();
880
+ setDefaultSlowLaneClient(null);
881
+ },
882
+ };
883
+ },
854
884
  });
855
885
  };
@@ -174,6 +174,8 @@ interface MinimalGpu {
174
174
  // ─── Constants ───────────────────────────────────────────────────────────────
175
175
 
176
176
  const DEFAULT_DEBOUNCE_MS = 300;
177
+ // eslint-disable-next-line require-unicode-regexp
178
+ const OOM_REGEX = /\boom\b/;
177
179
 
178
180
  export const LOCAL_MLC_CAUSAL_MODEL_ID = 'SmolLM2-135M-Instruct-q0f16-MLC';
179
181
 
@@ -369,7 +371,7 @@ let bePayloadDataPromise: Promise<void> | undefined;
369
371
  * :param shape: `'object'` if the source JSON is `{...}`, `'array'` if `[...]`.
370
372
  * :returns: The parsed JSON value, or `null` if neither interop mode applies.
371
373
  */
372
- const unwrapJsonModule = <T>(mod: unknown, shape: 'object' | 'array'): T | null => {
374
+ function unwrapJsonModule<T>(mod: unknown, shape: 'object' | 'array'): T | null {
373
375
  if (mod == null || typeof mod !== 'object') {
374
376
  return null;
375
377
  }
@@ -411,7 +413,7 @@ const unwrapJsonModule = <T>(mod: unknown, shape: 'object' | 'array'): T | null
411
413
  }
412
414
 
413
415
  return null;
414
- };
416
+ }
415
417
 
416
418
  /**
417
419
  * Lazily load and build the BE-parity lookup tables from their JSON payloads.
@@ -769,8 +771,7 @@ export const createLocalSlowLaneClient = (
769
771
  }
770
772
  if (
771
773
  lower.includes('out of memory') ||
772
- // eslint-disable-next-line require-unicode-regexp
773
- /\boom\b/.test(lower) ||
774
+ OOM_REGEX.test(lower) ||
774
775
  lower.includes('allocation') ||
775
776
  lower.includes('exceeds') ||
776
777
  lower.includes('buffer size') ||
@@ -1004,14 +1005,15 @@ export const createLocalSlowLaneClient = (
1004
1005
  const lmText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_TOKENS);
1005
1006
  const semanticText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_WORDS);
1006
1007
  const arcticInput = wrapForArctic(semanticText);
1007
- const captureCompletionTime = <T>(
1008
+ function captureCompletionTime<T>(
1008
1009
  promise: Promise<T>,
1009
1010
  onResolved: (resolvedAt: number) => void,
1010
- ): Promise<T> =>
1011
- promise.then((value: T) => {
1011
+ ): Promise<T> {
1012
+ return promise.then((value: T) => {
1012
1013
  onResolved(performance.now());
1013
1014
  return value;
1014
1015
  });
1016
+ }
1015
1017
 
1016
1018
  if (isAutocompleteDebugEnabled()) {
1017
1019
  // eslint-disable-next-line no-console
@@ -29,6 +29,8 @@ export interface TypeaheadEncodingsResponse {
29
29
 
30
30
  // eslint-disable-next-line require-unicode-regexp
31
31
  const WORD_BOUNDARY_CHARS = /[\s.,;:!?]/;
32
+ // eslint-disable-next-line require-unicode-regexp
33
+ const TRAILING_SLASH_REGEX = /\/$/;
32
34
  const DEFAULT_DEBOUNCE_MS = 300;
33
35
 
34
36
  /**
@@ -94,8 +96,7 @@ export const createSlowLaneClient = (
94
96
  return;
95
97
  }
96
98
 
97
- // eslint-disable-next-line require-unicode-regexp
98
- const url = `${baseUrl.replace(/\/$/, '')}${endpoint}`;
99
+ const url = `${baseUrl.replace(TRAILING_SLASH_REGEX, '')}${endpoint}`;
99
100
  const payload: TypeaheadEncodingsRequest = {
100
101
  text,
101
102
  session_id: sessionId,
@@ -29,6 +29,12 @@ import { getStoredContextVector, getStoredLmLogits } from './slow-lane-client';
29
29
 
30
30
  // eslint-disable-next-line require-unicode-regexp
31
31
  const PUNCTUATION_BOUNDARY_REGEX = /^[.,;:!?()\[\]{}"'`]+|[.,;:!?()\[\]{}"'`]+$/g;
32
+ // eslint-disable-next-line require-unicode-regexp
33
+ const WHITESPACE_SPLIT_REGEX = /\s+/;
34
+ // eslint-disable-next-line require-unicode-regexp
35
+ const SENTENCE_BOUNDARY_REGEX = /[\n.?!]+/;
36
+ // eslint-disable-next-line require-unicode-regexp
37
+ const TRAILING_WHITESPACE_REGEX = /\s$/;
32
38
 
33
39
  const MIN_PREFIX_LENGTH = 3;
34
40
  const MAX_CANDIDATES = 200;
@@ -275,8 +281,8 @@ const getContextVectorForScoring = (textBefore: string): Float32Array | null =>
275
281
 
276
282
  const tokenize = (text: string): string[] => {
277
283
  const tokens: string[] = [];
278
- // eslint-disable-next-line require-unicode-regexp, @atlassian/perf-linting/no-expensive-split-replace
279
- for (const raw of text.toLowerCase().split(/\s+/)) {
284
+ // eslint-disable-next-line @atlassian/perf-linting/no-expensive-split-replace
285
+ for (const raw of text.toLowerCase().split(WHITESPACE_SPLIT_REGEX)) {
280
286
  // eslint-disable-next-line @atlassian/perf-linting/no-expensive-split-replace
281
287
  const clean = raw.replace(PUNCTUATION_BOUNDARY_REGEX, '');
282
288
  if (clean.length >= 2) {
@@ -288,12 +294,10 @@ const tokenize = (text: string): string[] => {
288
294
 
289
295
  const extractPreviousWord = (text: string): string => {
290
296
  // Only consider the current sentence/line the user is typing in.
291
- // eslint-disable-next-line require-unicode-regexp
292
- const sentences = text.split(/[\n.?!]+/);
297
+ const sentences = text.split(SENTENCE_BOUNDARY_REGEX);
293
298
  const currentSentence = sentences[sentences.length - 1];
294
299
 
295
- // eslint-disable-next-line require-unicode-regexp
296
- const words = currentSentence.trimEnd().split(/\s+/);
300
+ const words = currentSentence.trimEnd().split(WHITESPACE_SPLIT_REGEX);
297
301
  return words.length >= 2 ? words[words.length - 2] : '';
298
302
  };
299
303
 
@@ -442,8 +446,7 @@ export const predict = (textBefore: string): string | null => {
442
446
  // }
443
447
 
444
448
  // ── Step 2: Prefix completion (≥3 chars typed) ──────────────────────────
445
- // eslint-disable-next-line require-unicode-regexp
446
- if (textBefore.length > 0 && /\s$/.test(textBefore)) {
449
+ if (textBefore.length > 0 && TRAILING_WHITESPACE_REGEX.test(textBefore)) {
447
450
  return null;
448
451
  }
449
452
 
@@ -715,7 +718,7 @@ interface VocabularyJson {
715
718
  * array and a sparse numeric-keyed object are emitted identically as named
716
719
  * exports. Kept in lock-step with the matching helper in local-slow-lane-client.ts.
717
720
  */
718
- const unwrapJsonModule = <T>(mod: unknown, shape: 'object' | 'array'): T | null => {
721
+ function unwrapJsonModule<T>(mod: unknown, shape: 'object' | 'array'): T | null {
719
722
  if (mod == null || typeof mod !== 'object') {
720
723
  return null;
721
724
  }
@@ -743,7 +746,7 @@ const unwrapJsonModule = <T>(mod: unknown, shape: 'object' | 'array'): T | null
743
746
  }
744
747
 
745
748
  return null;
746
- };
749
+ }
747
750
 
748
751
  export const loadVectorsAsync = async (options?: {
749
752
  getBinaryUrl?: () => Promise<string>;