@atlaskit/editor-plugin-autocomplete 9.0.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,16 @@ declare global {
28
28
  isEnabled: () => boolean;
29
29
  /** Whether verbose logging (candidate tables + extra detail) is on. */
30
30
  isVerbose: () => boolean;
31
+ /**
32
+ * Snapshot of the L1 session boosts — the vocabulary words this session
33
+ * has seen and how often — or one family of them:
34
+ * `__atlCtcDebug__.session('poll')`.
35
+ *
36
+ * Installed by the predictor rather than declared with the rest of the
37
+ * API, and typed as `unknown` so the return shape can live with the module
38
+ * that owns it instead of creating a cycle back to this one.
39
+ */
40
+ session?: (prefix?: string) => unknown;
31
41
  };
32
42
  }
33
43
  }
@@ -60,3 +70,12 @@ export declare const ctcSection: (label: string, body: string) => void;
60
70
  export declare const ctcTag: (tag: string, body: string, tagStyle?: string) => void;
61
71
  export declare const isAutocompleteDebugEnabled: () => boolean;
62
72
  export declare const isAutocompleteDebugVerbose: () => boolean;
73
+ /**
74
+ * Hang the L1 session-boost snapshot off the console API.
75
+ *
76
+ * Unlike the log helpers this is available whether or not debug is enabled:
77
+ * inspecting state on demand is not logging, and asking someone to turn on
78
+ * logging and retype to find out what the session already holds defeats the
79
+ * point of being able to ask.
80
+ */
81
+ export declare const registerCtcSessionInspector: (inspect: (prefix?: string) => unknown) => void;
@@ -12,7 +12,10 @@
12
12
  * Falls back to cold mode (freq-only) when vectors not yet loaded.
13
13
  *
14
14
  * Session personalization (L1): words the user types are incrementally boosted
15
- * via incrementSessionFreq(), called on word boundaries from the plugin.
15
+ * via incrementSessionFreq(), called on word boundaries from the plugin, and
16
+ * words in ingested context text via ingestDocumentPage(). What the session has
17
+ * boosted is visible at any time from the console: `__atlCtcDebug__.session()`,
18
+ * or `__atlCtcDebug__.session('poll')` for one family — see inspectSessionBoosts.
16
19
  */
17
20
  import type { TermType } from './scoring-pipeline';
18
21
  export interface WeightedTerm {
@@ -111,6 +114,38 @@ export declare const incrementSessionFreq: (word: string) => void;
111
114
  * calling context does not yet have a page value available.
112
115
  */
113
116
  export declare const ingestDocumentPage: (pageContent: string | undefined) => void;
117
+ interface SessionWordSnapshot {
118
+ /** Times this session has seen it: words typed plus words in ingested text. */
119
+ sessionFreq: number;
120
+ /** No corpus frequency behind it, so L1 is the whole of its standing. */
121
+ sessionOnly: boolean;
122
+ surface: string;
123
+ /** Corpus frequency shipped with the vocabulary, for scale against the boost. */
124
+ tenantFreq: number;
125
+ }
126
+ export interface SessionSnapshot {
127
+ /** How many words hold a boost, whether or not they are listed below. */
128
+ boosted: number;
129
+ /** Ceiling on `words`; beyond it the weakest boosts are left out of the listing. */
130
+ limit: number;
131
+ /** The prefix asked about, when one was passed. */
132
+ prefix?: string;
133
+ /** Strongest boost first, then alphabetically. */
134
+ words: SessionWordSnapshot[];
135
+ }
136
+ /**
137
+ * Read the session's L1 boosts, optionally narrowed to a prefix.
138
+ *
139
+ * Installed as `__atlCtcDebug__.session()`, with `__atlCtcDebug__.session('poll')`
140
+ * to ask about one family. Returned rather than logged, so the console renders it
141
+ * as an inspectable object and a caller can assert on it.
142
+ *
143
+ * Only words the vocabulary already holds can carry a boost, because both writers
144
+ * go through `incrementSessionFreq` and it only finds existing nodes. An ingested
145
+ * word absent from the vocabulary is therefore missing from here and always will
146
+ * be.
147
+ */
148
+ export declare const inspectSessionBoosts: (prefix?: string) => SessionSnapshot;
114
149
  /**
115
150
  * Result of a prediction: the ghost tail to insert plus an immutable record of
116
151
  * the evidence that authorized the UI commitment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-autocomplete",
3
- "version": "9.0.0",
3
+ "version": "9.1.0",
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": "^119.4.0",
30
+ "@atlaskit/editor-common": "^119.9.0",
31
31
  "@atlaskit/editor-plugin-analytics": "^15.0.0",
32
32
  "react": "^18.2.0 || ^19.2.0"
33
33
  },
@@ -669,7 +669,67 @@ export const createAutocompletePlugin = (
669
669
  // Set when the plugin is torn down so in-flight getContext() resolutions don't
670
670
  // mutate the global text-predictor state after destruction.
671
671
  let destroyed = false;
672
- const ingestedContextTexts = new Set<string>();
672
+ // L1 ingestion cannot run before the vocabulary has loaded: `incrementSessionFreq`
673
+ // only touches trie nodes that already exist, so an empty trie silently rejects
674
+ // every word. Text that arrives first — which a page usually does, since the
675
+ // chat resolves it on focus in the same tick as the load starts — is held here
676
+ // and applied once the load has settled.
677
+ const pendingSessionContextTexts = new Set<string>();
678
+ const sessionIngestedContextTexts = new Set<string>();
679
+ let isVocabularyReady = false;
680
+ let vocabularyLoadPromise: Promise<void> | undefined;
681
+
682
+ const addBoundedContextText = (texts: Set<string>, text: string): void => {
683
+ texts.add(text);
684
+ // Evict oldest entries (Set preserves insertion order) to bound memory.
685
+ while (texts.size > MAX_INGESTED_CONTEXT_TEXTS) {
686
+ const oldest = texts.values().next().value;
687
+ if (oldest === undefined) {
688
+ break;
689
+ }
690
+ texts.delete(oldest);
691
+ }
692
+ };
693
+
694
+ const flushPendingSessionContext = (): void => {
695
+ if (!isVocabularyReady || destroyed) {
696
+ return;
697
+ }
698
+ for (const text of pendingSessionContextTexts) {
699
+ if (!sessionIngestedContextTexts.has(text)) {
700
+ ingestDocumentPage(text);
701
+ addBoundedContextText(sessionIngestedContextTexts, text);
702
+ }
703
+ pendingSessionContextTexts.delete(text);
704
+ }
705
+ };
706
+
707
+ const ensureVocabularyReady = (): Promise<void> => {
708
+ if (isVocabularyReady) {
709
+ flushPendingSessionContext();
710
+ return Promise.resolve();
711
+ }
712
+ vocabularyLoadPromise ??= loadDefaultVocabulary({
713
+ isLocalLLM: options?.useLocalModel ?? false,
714
+ surface,
715
+ })
716
+ .then(() => {
717
+ if (destroyed) {
718
+ return;
719
+ }
720
+ isVocabularyReady = true;
721
+ flushPendingSessionContext();
722
+ })
723
+ .catch((error) => {
724
+ // Do not consume the pending text on failure. A later focus retries the
725
+ // load and can still apply the original context exactly once.
726
+ vocabularyLoadPromise = undefined;
727
+ logException(error as Error, {
728
+ location: 'editor-plugin-autocomplete/loadDefaultVocabulary',
729
+ });
730
+ });
731
+ return vocabularyLoadPromise;
732
+ };
673
733
 
674
734
  const logContextResolved = (source: string, context?: AutocompleteContext): void => {
675
735
  if (!isAutocompleteDebugEnabled()) {
@@ -698,20 +758,11 @@ export const createAutocompletePlugin = (
698
758
  resolvedContext = { ...resolvedContext, ...definedContext };
699
759
 
700
760
  const ingestContextText = (text?: string): void => {
701
- if (!text || ingestedContextTexts.has(text)) {
761
+ if (!text || sessionIngestedContextTexts.has(text)) {
702
762
  return;
703
763
  }
704
764
 
705
- ingestedContextTexts.add(text);
706
- // Evict oldest entries (Set preserves insertion order) to bound memory.
707
- while (ingestedContextTexts.size > MAX_INGESTED_CONTEXT_TEXTS) {
708
- const oldest = ingestedContextTexts.values().next().value;
709
- if (oldest === undefined) {
710
- break;
711
- }
712
- ingestedContextTexts.delete(oldest);
713
- }
714
- ingestDocumentPage(text);
765
+ addBoundedContextText(pendingSessionContextTexts, text);
715
766
  };
716
767
 
717
768
  ingestContextText(context.fullPageContent);
@@ -719,6 +770,7 @@ export const createAutocompletePlugin = (
719
770
  for (const siblingCommentContent of context.siblingCommentsContents ?? []) {
720
771
  ingestContextText(siblingCommentContent);
721
772
  }
773
+ flushPendingSessionContext();
722
774
 
723
775
  // Context arrived after word boundaries may already have fired. Re-send
724
776
  // slow-lane context immediately so the next inference includes the thread.
@@ -1198,14 +1250,7 @@ export const createAutocompletePlugin = (
1198
1250
  return false;
1199
1251
  }
1200
1252
 
1201
- loadDefaultVocabulary({
1202
- isLocalLLM: options?.useLocalModel ?? false,
1203
- surface,
1204
- }).catch((error) => {
1205
- logException(error as Error, {
1206
- location: 'editor-plugin-autocomplete/loadDefaultVocabulary',
1207
- });
1208
- });
1253
+ void ensureVocabularyReady();
1209
1254
  loadVectorsAsync({
1210
1255
  isLocalLLM: options?.useLocalModel ?? false,
1211
1256
  surface,
@@ -1313,7 +1358,8 @@ export const createAutocompletePlugin = (
1313
1358
  if (hasDestroy(slowLaneClient)) {
1314
1359
  slowLaneClient.destroy();
1315
1360
  }
1316
- ingestedContextTexts.clear();
1361
+ pendingSessionContextTexts.clear();
1362
+ sessionIngestedContextTexts.clear();
1317
1363
  },
1318
1364
  };
1319
1365
  },
@@ -29,6 +29,16 @@ declare global {
29
29
  isEnabled: () => boolean;
30
30
  /** Whether verbose logging (candidate tables + extra detail) is on. */
31
31
  isVerbose: () => boolean;
32
+ /**
33
+ * Snapshot of the L1 session boosts — the vocabulary words this session
34
+ * has seen and how often — or one family of them:
35
+ * `__atlCtcDebug__.session('poll')`.
36
+ *
37
+ * Installed by the predictor rather than declared with the rest of the
38
+ * API, and typed as `unknown` so the return shape can live with the module
39
+ * that owns it instead of creating a cycle back to this one.
40
+ */
41
+ session?: (prefix?: string) => unknown;
32
42
  };
33
43
  }
34
44
  }
@@ -120,7 +130,7 @@ const printLegend = (verbose: boolean): void => {
120
130
  );
121
131
  // eslint-disable-next-line no-console
122
132
  console.log(
123
- '%cInspect%c __atlCtcDebug__.enable("verbose") · .disable()',
133
+ '%cInspect%c __atlCtcDebug__.enable("verbose") · .disable() · .session() (L1 boosts, narrow to a family with .session("poll"))',
124
134
  CTC_STYLES.section,
125
135
  CTC_STYLES.body,
126
136
  );
@@ -163,5 +173,20 @@ export const isAutocompleteDebugEnabled = (): boolean => getDebugApi()?.isEnable
163
173
 
164
174
  export const isAutocompleteDebugVerbose = (): boolean => getDebugApi()?.isVerbose() ?? false;
165
175
 
176
+ /**
177
+ * Hang the L1 session-boost snapshot off the console API.
178
+ *
179
+ * Unlike the log helpers this is available whether or not debug is enabled:
180
+ * inspecting state on demand is not logging, and asking someone to turn on
181
+ * logging and retype to find out what the session already holds defeats the
182
+ * point of being able to ask.
183
+ */
184
+ export const registerCtcSessionInspector = (inspect: (prefix?: string) => unknown): void => {
185
+ const api = getDebugApi();
186
+ if (api) {
187
+ api.session = inspect;
188
+ }
189
+ };
190
+
166
191
  // Eagerly install so the console API is available on load, regardless of call order.
167
192
  getDebugApi();
@@ -12,7 +12,10 @@
12
12
  * Falls back to cold mode (freq-only) when vectors not yet loaded.
13
13
  *
14
14
  * Session personalization (L1): words the user types are incrementally boosted
15
- * via incrementSessionFreq(), called on word boundaries from the plugin.
15
+ * via incrementSessionFreq(), called on word boundaries from the plugin, and
16
+ * words in ingested context text via ingestDocumentPage(). What the session has
17
+ * boosted is visible at any time from the console: `__atlCtcDebug__.session()`,
18
+ * or `__atlCtcDebug__.session('poll')` for one family — see inspectSessionBoosts.
16
19
  */
17
20
 
18
21
  import { EXPERIENCE_NAME, failExp, startExp, succeedExp } from '../analytics/ufo';
@@ -35,6 +38,7 @@ import {
35
38
  ctcTag,
36
39
  isAutocompleteDebugEnabled,
37
40
  isAutocompleteDebugVerbose,
41
+ registerCtcSessionInspector,
38
42
  } from './debug-mode';
39
43
  import {
40
44
  loadGrammarDataAsync,
@@ -388,6 +392,44 @@ class WeightedWordTrie {
388
392
  node.sessionFreq += 1;
389
393
  return true;
390
394
  }
395
+
396
+ /**
397
+ * Every word carrying a session boost, optionally limited to one prefix's
398
+ * subtree. Unlike `getCandidates` a word equal to the prefix is included,
399
+ * since the question here is what the session holds rather than what could
400
+ * still be typed.
401
+ *
402
+ * Walks the trie instead of reading an index, so nothing has to be kept in
403
+ * step on the write path for the sake of being able to ask.
404
+ */
405
+ collectSessionBoosted(prefix: string = ''): Candidate[] {
406
+ let node = this.root;
407
+ for (const char of prefix.toLowerCase()) {
408
+ const next = node.children.get(char);
409
+ if (!next) {
410
+ return [];
411
+ }
412
+ node = next;
413
+ }
414
+
415
+ const boosted: Candidate[] = [];
416
+ const stack: TrieNode[] = [node];
417
+
418
+ while (stack.length > 0) {
419
+ const current = stack.pop();
420
+ if (!current) {
421
+ continue;
422
+ }
423
+ if (current.word !== null && current.sessionFreq > 0) {
424
+ boosted.push({ word: current.word, node: current });
425
+ }
426
+ for (const child of current.children.values()) {
427
+ stack.push(child);
428
+ }
429
+ }
430
+
431
+ return boosted;
432
+ }
391
433
  }
392
434
 
393
435
  // L1/L2 Trie (Session + Atlassian Domain)
@@ -823,7 +865,11 @@ export const ingestDocumentPage = (pageContent: string | undefined): void => {
823
865
  }
824
866
 
825
867
  if (isAutocompleteDebugEnabled() && validBoostedWords.size > 0) {
826
- ctcTag('init', `L1 session primed ${validBoostedWords.size} words from page`, CTC_STYLES.brand);
868
+ ctcTag(
869
+ 'init',
870
+ `L1 session primed ${validBoostedWords.size} words from page · __atlCtcDebug__.session() to inspect`,
871
+ CTC_STYLES.brand,
872
+ );
827
873
  if (isAutocompleteDebugVerbose()) {
828
874
  // eslint-disable-next-line no-console
829
875
  console.dir(Array.from(validBoostedWords).sort());
@@ -831,6 +877,74 @@ export const ingestDocumentPage = (pageContent: string | undefined): void => {
831
877
  }
832
878
  };
833
879
 
880
+ /**
881
+ * How many boosted words `inspectSessionBoosts` lists.
882
+ *
883
+ * A page ingest can boost thousands, and a list that long is not read. The
884
+ * strongest boosts are the ones that change an ordering, and `boosted` still
885
+ * reports the full size, so the cap loses nothing but volume.
886
+ */
887
+ const MAX_LISTED_SESSION_WORDS = 50;
888
+
889
+ interface SessionWordSnapshot {
890
+ /** Times this session has seen it: words typed plus words in ingested text. */
891
+ sessionFreq: number;
892
+ /** No corpus frequency behind it, so L1 is the whole of its standing. */
893
+ sessionOnly: boolean;
894
+ surface: string;
895
+ /** Corpus frequency shipped with the vocabulary, for scale against the boost. */
896
+ tenantFreq: number;
897
+ }
898
+
899
+ export interface SessionSnapshot {
900
+ /** How many words hold a boost, whether or not they are listed below. */
901
+ boosted: number;
902
+ /** Ceiling on `words`; beyond it the weakest boosts are left out of the listing. */
903
+ limit: number;
904
+ /** The prefix asked about, when one was passed. */
905
+ prefix?: string;
906
+ /** Strongest boost first, then alphabetically. */
907
+ words: SessionWordSnapshot[];
908
+ }
909
+
910
+ /**
911
+ * Read the session's L1 boosts, optionally narrowed to a prefix.
912
+ *
913
+ * Installed as `__atlCtcDebug__.session()`, with `__atlCtcDebug__.session('poll')`
914
+ * to ask about one family. Returned rather than logged, so the console renders it
915
+ * as an inspectable object and a caller can assert on it.
916
+ *
917
+ * Only words the vocabulary already holds can carry a boost, because both writers
918
+ * go through `incrementSessionFreq` and it only finds existing nodes. An ingested
919
+ * word absent from the vocabulary is therefore missing from here and always will
920
+ * be.
921
+ */
922
+ export const inspectSessionBoosts = (prefix?: string): SessionSnapshot => {
923
+ const boosted = wordTrie.collectSessionBoosted(prefix ?? '');
924
+ return {
925
+ boosted: boosted.length,
926
+ limit: MAX_LISTED_SESSION_WORDS,
927
+ ...(prefix === undefined ? {} : { prefix }),
928
+ words: boosted
929
+ .sort(
930
+ (a, b) =>
931
+ b.node.sessionFreq - a.node.sessionFreq ||
932
+ a.word.localeCompare(b.word, 'en', { numeric: true, sensitivity: 'base' }),
933
+ )
934
+ .slice(0, MAX_LISTED_SESSION_WORDS)
935
+ .map(({ node, word }) => ({
936
+ sessionFreq: node.sessionFreq,
937
+ sessionOnly: node.tenantFreq === 0,
938
+ surface: word,
939
+ tenantFreq: node.tenantFreq,
940
+ })),
941
+ };
942
+ };
943
+
944
+ // At module scope so the console answers before the first keystroke, which is
945
+ // when someone reaching for it usually asks.
946
+ registerCtcSessionInspector(inspectSessionBoosts);
947
+
834
948
  /**
835
949
  * Result of a prediction: the ghost tail to insert plus an immutable record of
836
950
  * the evidence that authorized the UI commitment.