@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.
@@ -89,7 +89,7 @@ const printLegend = verbose => {
89
89
  // eslint-disable-next-line no-console
90
90
  console.log('%cPlanes%c [CTC:init] loads · [CTC:signal] semantic/network · [CTC:model] primes/exact · [CTC:model-cost] prefill/decode · [CTC:readiness] deadline progress', CTC_STYLES.section, CTC_STYLES.body);
91
91
  // eslint-disable-next-line no-console
92
- console.log('%cInspect%c __atlCtcDebug__.enable("verbose") · .disable() · .session() (L1 boosts, narrow to a family with .session("poll"))', CTC_STYLES.section, CTC_STYLES.body);
92
+ console.log('%cInspect%c __atlCtcDebug__.enable("verbose") · .disable() · .harvest() (session inline code) · .session() (L1 boosts) — both narrow to a prefix, e.g. .session("poll")', CTC_STYLES.section, CTC_STYLES.body);
93
93
  // eslint-disable-next-line no-console
94
94
  console.groupEnd();
95
95
  };
@@ -134,13 +134,24 @@ export const isAutocompleteDebugVerbose = () => {
134
134
  };
135
135
 
136
136
  /**
137
- * Hang the L1 session-boost snapshot off the console API.
137
+ * Hang the harvest snapshot off the console API.
138
138
  *
139
139
  * Unlike the log helpers this is available whether or not debug is enabled:
140
140
  * inspecting state on demand is not logging, and asking someone to turn on
141
141
  * logging and retype to find out what the session already holds defeats the
142
142
  * point of being able to ask.
143
143
  */
144
+ export const registerCtcHarvestInspector = inspect => {
145
+ const api = getDebugApi();
146
+ if (api) {
147
+ api.harvest = inspect;
148
+ }
149
+ };
150
+
151
+ /**
152
+ * Hang the L1 session-boost snapshot off the console API, on the same terms as
153
+ * `registerCtcHarvestInspector`: available whether or not logging is on.
154
+ */
144
155
  export const registerCtcSessionInspector = inspect => {
145
156
  const api = getDebugApi();
146
157
  if (api) {
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Inline-code harvester — the Class B candidate source.
3
+ *
4
+ * Class A terms (Atlassian, Confluence) already carry tenant frequencies, a word
5
+ * vector and canonical token ids, so they compete inside the scored pool. Class B
6
+ * terms — `ml-studio` and friends — exist only in the session and cannot be
7
+ * suggested at all today: `incrementSessionFreq` only touches trie nodes that
8
+ * already exist, so an unknown session word is silently discarded.
9
+ *
10
+ * Two sources carry the signal. Assistant replies arrive as markdown strings and
11
+ * keep their backticks; the live document has had its backticks consumed by the
12
+ * text-formatting input rule, so its spans are found through the `code` mark
13
+ * instead. Human chat messages arrive as ADF and are flattened to bare text, so
14
+ * they contribute no spans — which is why no author check is needed here.
15
+ *
16
+ * A harvested surface cannot be scored: there is no frequency to rank it, no
17
+ * vector to place it in context and no canonical token ids to price it under the
18
+ * model. Being marked as code is the whole of the evidence, so what stands in for
19
+ * a score is where the surface is allowed to speak — only on a prefix no
20
+ * vocabulary reached, and only once the scored path has finished with it.
21
+ *
22
+ * Sightings are still counted, per source and by different rules, but they do
23
+ * not admit a surface. They order the set under eviction, and they pick between
24
+ * two surfaces that complete the same typed prefix — see `HarvestedTerm` and
25
+ * `findHarvestedCompletion`.
26
+ *
27
+ * What the session is holding is visible at any time from the console:
28
+ * `__atlCtcDebug__.harvest()`, or `__atlCtcDebug__.harvest('ml-s')` to ask what
29
+ * a prefix would be offered — see `inspectInlineCodeHarvest`.
30
+ */
31
+
32
+ import { CTC_STYLES, ctcTag, isAutocompleteDebugEnabled, isAutocompleteDebugVerbose, registerCtcHarvestInspector } from './debug-mode';
33
+ import { lookupVocabularySource } from './text-predictor';
34
+
35
+ /** Longer than this is a command or a path, not a term anyone wants completed. */
36
+ const MAX_IDENTIFIER_LENGTH = 50;
37
+
38
+ /**
39
+ * Longer than the predictor's DISPLAY_MIN_PREFIX_LENGTH of 3.
40
+ *
41
+ * The scored path can afford three characters because it has a posterior and a
42
+ * winner margin to answer for the fourth. This path has neither, so it asks the
43
+ * user to commit further before it will guess.
44
+ */
45
+ const MIN_TYPED_PREFIX_LENGTH = 4;
46
+
47
+ /** Mirrors the predictor's MIN_SUGGESTION_LENGTH. */
48
+ const MIN_GHOST_LENGTH = 3;
49
+
50
+ /**
51
+ * Sightings needed before a surface may be offered, counting both sources.
52
+ *
53
+ * One, because asking for two reads the replies this exists for as no evidence
54
+ * at all: a reply answering "what are our service names" names each one exactly
55
+ * once, and ten terms mentioned once each is the normal shape of the answer, not
56
+ * a weak signal. What keeps noise out is not a count — see `isDisplayEligible`.
57
+ */
58
+ const MIN_OCCURRENCES = 1;
59
+
60
+ /**
61
+ * Cap on the harvested set.
62
+ *
63
+ * Not a memory bound — surfaces are capped at 50 characters. It is there so a
64
+ * long session's early terms cannot sit in the set competing with what is being
65
+ * discussed now, and so the display path's scan stays bounded. Provisional:
66
+ * size it off the funnel numbers from a branch deploy rather than this guess.
67
+ */
68
+ const MAX_HARVESTED_TERMS = 200;
69
+
70
+ /**
71
+ * A surface shorter than a qualifying prefix plus a qualifying ghost can never
72
+ * be shown. Recorded rather than filtered, so the funnel shows how much of the
73
+ * harvest is unusable for that reason alone.
74
+ */
75
+ const DISPLAYABLE_MIN_LENGTH = MIN_TYPED_PREFIX_LENGTH + MIN_GHOST_LENGTH;
76
+ const FENCED_REGION_REGEX = /```[\s\S]*?```/gu;
77
+ const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/gu;
78
+ const WHITESPACE_REGEX = /\s/u;
79
+ const STARTS_WITH_LETTER_REGEX = /^\p{L}/u;
80
+
81
+ /** Why this surface was chosen over the others that completed the prefix. */
82
+
83
+ const emptyFunnel = () => ({
84
+ accepted: 0,
85
+ rejectedInFence: 0,
86
+ rejectedKnownL2: 0,
87
+ rejectedKnownL3: 0,
88
+ rejectedNonAlphaStart: 0,
89
+ rejectedTooLong: 0,
90
+ rejectedWhitespace: 0,
91
+ spansFound: 0
92
+ });
93
+
94
+ // Session-scoped, and only correct because the plugin resets it on destroy: the
95
+ // map holds user and assistant content, so surviving an unmount would leak one
96
+ // conversation's terms into the next.
97
+ const harvested = new Map();
98
+ // Two funnels because the two sources are read on different schedules. Ingested
99
+ // text is seen once, so its funnel accumulates; the document is re-walked on
100
+ // every word boundary, so its funnel is replaced to stay a description of the
101
+ // document rather than of how long the session has run.
102
+ let contextFunnel = emptyFunnel();
103
+ let documentFunnel = emptyFunnel();
104
+ let evictedTerms = 0;
105
+ let lastLoggedSignature = '';
106
+ const collectFencedRanges = text => {
107
+ const ranges = [];
108
+ for (const match of text.matchAll(FENCED_REGION_REGEX)) {
109
+ if (match.index !== undefined) {
110
+ ranges.push([match.index, match.index + match[0].length]);
111
+ }
112
+ }
113
+ return ranges;
114
+ };
115
+ const isInsideFence = (ranges, index) => ranges.some(([start, end]) => index >= start && index < end);
116
+
117
+ /** The cleaned surface if it survives intake, or null. Records the rejection. */
118
+ const admitCandidate = (raw, funnel) => {
119
+ // Markdown permits one space of padding inside the ticks, so trim before
120
+ // testing for the internal whitespace that separates a term from a command.
121
+ const candidate = raw.trim();
122
+ if (candidate.length === 0 || WHITESPACE_REGEX.test(candidate)) {
123
+ funnel.rejectedWhitespace++;
124
+ return null;
125
+ }
126
+ if (candidate.length > MAX_IDENTIFIER_LENGTH) {
127
+ funnel.rejectedTooLong++;
128
+ return null;
129
+ }
130
+ // Flags (`-d`, `--open-url`) match every other rule and are worthless to
131
+ // complete, so the leading character has to be a letter.
132
+ if (!STARTS_WITH_LETTER_REGEX.test(candidate)) {
133
+ funnel.rejectedNonAlphaStart++;
134
+ return null;
135
+ }
136
+ // A surface the scored path can already serve does not belong here. Harvesting
137
+ // it would put a candidate with no frequencies, no vector and no canonical
138
+ // token ids up against one that has all three.
139
+ const known = lookupVocabularySource(candidate);
140
+ if (known === 'l2') {
141
+ funnel.rejectedKnownL2++;
142
+ return null;
143
+ }
144
+ if (known === 'l3') {
145
+ funnel.rejectedKnownL3++;
146
+ return null;
147
+ }
148
+ return candidate;
149
+ };
150
+
151
+ /** Total mentions behind a surface, across both sources. */
152
+ const sightingsOf = term => term.documentSpans + term.replyOccurrences;
153
+
154
+ /**
155
+ * Order two surfaces that are competing for the same typed prefix, best first.
156
+ *
157
+ * Sightings lead, because the number of times the session named a thing is the
158
+ * only quantity this module has that says anything about which one is being
159
+ * discussed. The alphabetical tie-break is arbitrary and chosen for being
160
+ * arbitrary in a stable way: on a tie there is nothing to prefer, and a rule
161
+ * that always resolves the same way means the same prefix produces the same
162
+ * ghost every time rather than one that follows harvest order.
163
+ *
164
+ * Numeric-aware so `model@v2` sorts before `model@v10` — versioned identifiers
165
+ * are common enough in this set to be worth not reading as strings.
166
+ */
167
+ const byCollapseOrder = (a, b) => sightingsOf(b) - sightingsOf(a) || a.surface.localeCompare(b.surface, 'en', {
168
+ numeric: true,
169
+ sensitivity: 'base'
170
+ });
171
+
172
+ /**
173
+ * Drop the weakest terms until the set is within its cap.
174
+ *
175
+ * Least evidence first, oldest sighting to break the tie. FIFO is the local
176
+ * precedent (MAX_INGESTED_CONTEXT_TEXTS in the plugin) but it is the wrong
177
+ * policy here: the earliest-harvested term may be the one the conversation is
178
+ * actually about, and the sighting count is the only ranking this module has.
179
+ */
180
+ const evictWeakestTerms = () => {
181
+ if (harvested.size <= MAX_HARVESTED_TERMS) {
182
+ return;
183
+ }
184
+ const ordered = Array.from(harvested.entries()).sort(([, a], [, b]) => sightingsOf(a) - sightingsOf(b) || a.lastSeenAt - b.lastSeenAt);
185
+ for (const [key] of ordered.slice(0, harvested.size - MAX_HARVESTED_TERMS)) {
186
+ harvested.delete(key);
187
+ evictedTerms++;
188
+ }
189
+ };
190
+
191
+ /** Count each admitted surface once per occurrence within a single pass. */
192
+ const tallySpans = surfaces => {
193
+ const counts = new Map();
194
+ for (const surface of surfaces) {
195
+ const key = surface.toLowerCase();
196
+ const existing = counts.get(key);
197
+ if (existing) {
198
+ existing.count++;
199
+ } else {
200
+ counts.set(key, {
201
+ count: 1,
202
+ surface
203
+ });
204
+ }
205
+ }
206
+ return counts;
207
+ };
208
+
209
+ /**
210
+ * Harvest single-backtick spans from a markdown-ish string. Fenced regions are
211
+ * skipped: they hold commands and file paths, and their contents are whitespace
212
+ * separated anyway.
213
+ */
214
+ export const harvestInlineCodeFromText = text => {
215
+ if (!text) {
216
+ return;
217
+ }
218
+ const fencedRanges = collectFencedRanges(text);
219
+ const admitted = [];
220
+ for (const match of text.matchAll(INLINE_CODE_SPAN_REGEX)) {
221
+ if (match.index === undefined) {
222
+ continue;
223
+ }
224
+ contextFunnel.spansFound++;
225
+ if (isInsideFence(fencedRanges, match.index)) {
226
+ contextFunnel.rejectedInFence++;
227
+ continue;
228
+ }
229
+ const surface = admitCandidate(match[1], contextFunnel);
230
+ if (surface) {
231
+ admitted.push(surface);
232
+ }
233
+ }
234
+ const now = performance.now();
235
+ for (const [key, {
236
+ count,
237
+ surface
238
+ }] of tallySpans(admitted)) {
239
+ const existing = harvested.get(key);
240
+ if (existing) {
241
+ existing.replyOccurrences += count;
242
+ existing.lastSeenAt = now;
243
+ continue;
244
+ }
245
+ harvested.set(key, {
246
+ displayable: surface.length >= DISPLAYABLE_MIN_LENGTH,
247
+ documentSpans: 0,
248
+ lastSeenAt: now,
249
+ replyOccurrences: count,
250
+ surface
251
+ });
252
+ contextFunnel.accepted++;
253
+ }
254
+ evictWeakestTerms();
255
+ };
256
+
257
+ /**
258
+ * Harvest code-marked text from the live document. The backtick input rule fires
259
+ * on the closing tick and removes both delimiters, so a finished span is only
260
+ * findable through its mark. Code blocks are skipped for the same reason fenced
261
+ * regions are.
262
+ *
263
+ * The whole document is re-read, and what it finds replaces the previous
264
+ * document counts rather than adding to them.
265
+ */
266
+ export const harvestInlineCodeFromDoc = doc => {
267
+ documentFunnel = emptyFunnel();
268
+ const admitted = [];
269
+ doc.descendants(node => {
270
+ if (node.type.name === 'codeBlock') {
271
+ return false;
272
+ }
273
+ if (node.isText && node.text && node.marks.some(mark => mark.type.name === 'code')) {
274
+ documentFunnel.spansFound++;
275
+ const surface = admitCandidate(node.text, documentFunnel);
276
+ if (surface) {
277
+ admitted.push(surface);
278
+ }
279
+ }
280
+ return true;
281
+ });
282
+ const spans = tallySpans(admitted);
283
+ // Surfaces the document no longer holds lose their document evidence. They
284
+ // stay in the set on whatever reply evidence they have.
285
+ for (const [key, term] of harvested) {
286
+ if (!spans.has(key)) {
287
+ term.documentSpans = 0;
288
+ }
289
+ }
290
+ const now = performance.now();
291
+ for (const [key, {
292
+ count,
293
+ surface
294
+ }] of spans) {
295
+ const existing = harvested.get(key);
296
+ if (existing) {
297
+ existing.documentSpans = count;
298
+ existing.lastSeenAt = now;
299
+ continue;
300
+ }
301
+ harvested.set(key, {
302
+ displayable: surface.length >= DISPLAYABLE_MIN_LENGTH,
303
+ documentSpans: count,
304
+ lastSeenAt: now,
305
+ replyOccurrences: 0,
306
+ surface
307
+ });
308
+ documentFunnel.accepted++;
309
+ }
310
+ evictWeakestTerms();
311
+ };
312
+
313
+ /**
314
+ * Whether a surface still has a sighting behind it.
315
+ *
316
+ * Recurrence turned out to be the wrong thing to lean on. What separates a term
317
+ * worth offering from a word in prose is that someone marked it as code — the
318
+ * author with a code mark, or the model with backticks — and that is already
319
+ * true of everything in this set. The rules that keep noise out are elsewhere:
320
+ * the surface must be absent from L2 and L3, unique among harvested terms for
321
+ * the typed prefix, and on a prefix the scored path has no claim to.
322
+ *
323
+ * The case this still rejects is a withdrawn sighting: a document span the
324
+ * author deleted drops back to zero, and a term with no reply mention behind it
325
+ * stops being offered.
326
+ */
327
+ const isDisplayEligible = term => term.documentSpans + term.replyOccurrences >= MIN_OCCURRENCES;
328
+
329
+ /**
330
+ * Every harvested surface that could be shown for `typedPrefix`, best first.
331
+ *
332
+ * A surface whose remaining tail is too short to render is not a rival, it is a
333
+ * surface the user has finished typing. Neither is one whose sighting has been
334
+ * withdrawn. Both used to be counted as ambiguity and used to silence the path.
335
+ */
336
+ const collectCandidates = typedPrefix => {
337
+ const needle = typedPrefix.toLowerCase();
338
+ const candidates = [];
339
+ for (const term of harvested.values()) {
340
+ if (!term.surface.toLowerCase().startsWith(needle)) {
341
+ continue;
342
+ }
343
+ if (term.surface.length - typedPrefix.length < MIN_GHOST_LENGTH) {
344
+ continue;
345
+ }
346
+ if (!isDisplayEligible(term)) {
347
+ continue;
348
+ }
349
+ candidates.push(term);
350
+ }
351
+ return candidates.sort(byCollapseOrder);
352
+ };
353
+
354
+ /**
355
+ * The harvested surface to offer for `typedPrefix`, or null.
356
+ *
357
+ * Rivals are collapsed rather than treated as a reason to stay quiet. Silence
358
+ * was the original rule, on the grounds that two session surfaces sharing a
359
+ * prefix have nothing to separate them, and it was wrong for the case this path
360
+ * exists to serve: a reply that answers "what are our services" names ten of
361
+ * them, several share a stem, and abstaining meant the harvester went quiet on
362
+ * exactly the reply it was built for. Being wrong here costs one keystroke —
363
+ * the ghost is ignored and typing continues — and staying silent costs the
364
+ * feature.
365
+ *
366
+ * `collapsedBy` records which rule settled it so a surprising ghost can be
367
+ * explained after the fact rather than guessed at.
368
+ */
369
+ export const findHarvestedCompletion = typedPrefix => {
370
+ if (typedPrefix.length < MIN_TYPED_PREFIX_LENGTH) {
371
+ return null;
372
+ }
373
+ const [winner, runnerUp, ...rest] = collectCandidates(typedPrefix);
374
+ if (!winner) {
375
+ return null;
376
+ }
377
+ return {
378
+ collapsedBy: !runnerUp ? 'sole-match' : sightingsOf(winner) > sightingsOf(runnerUp) ? 'sightings' : 'alphabetical',
379
+ documentSpans: winner.documentSpans,
380
+ ghostText: winner.surface.slice(typedPrefix.length),
381
+ replyOccurrences: winner.replyOccurrences,
382
+ rivalSurfaces: (runnerUp ? [runnerUp, ...rest] : []).map(term => term.surface),
383
+ surface: winner.surface,
384
+ typedPrefixLength: typedPrefix.length
385
+ };
386
+ };
387
+ /**
388
+ * Read the session's harvest, optionally asking what `typedPrefix` would get.
389
+ *
390
+ * Installed as `__atlCtcDebug__.harvest()` and returned rather than logged, so
391
+ * the console renders it as an inspectable object and a caller can assert on it.
392
+ */
393
+ export const inspectInlineCodeHarvest = typedPrefix => ({
394
+ cap: MAX_HARVESTED_TERMS,
395
+ evicted: evictedTerms,
396
+ funnels: {
397
+ context: {
398
+ ...contextFunnel
399
+ },
400
+ liveDocument: {
401
+ ...documentFunnel
402
+ }
403
+ },
404
+ ...(typedPrefix === undefined ? {} : {
405
+ match: findHarvestedCompletion(typedPrefix)
406
+ }),
407
+ terms: Array.from(harvested.values()).sort(byCollapseOrder).map(term => ({
408
+ documentSpans: term.documentSpans,
409
+ longEnough: term.displayable,
410
+ offerable: isDisplayEligible(term),
411
+ replyOccurrences: term.replyOccurrences,
412
+ sightings: sightingsOf(term),
413
+ surface: term.surface
414
+ }))
415
+ });
416
+
417
+ // At module scope so the console API answers before the first keystroke, which
418
+ // is when someone reaching for it usually asks.
419
+ registerCtcHarvestInspector(inspectInlineCodeHarvest);
420
+ export const resetInlineCodeHarvest = () => {
421
+ harvested.clear();
422
+ contextFunnel = emptyFunnel();
423
+ documentFunnel = emptyFunnel();
424
+ evictedTerms = 0;
425
+ lastLoggedSignature = '';
426
+ };
427
+
428
+ /**
429
+ * Print the funnel when it has moved since the last print.
430
+ *
431
+ * Deliberately reports zero-span and all-rejected passes too. Logging only on a
432
+ * successful harvest makes "the replies held no inline code", "every span was
433
+ * filtered out" and "this never ran" indistinguishable, which are the three
434
+ * things worth telling apart.
435
+ */
436
+ export const logInlineCodeHarvest = trigger => {
437
+ if (!isAutocompleteDebugEnabled()) {
438
+ return;
439
+ }
440
+ const snapshot = inspectInlineCodeHarvest();
441
+ const offerable = snapshot.terms.filter(term => term.offerable && term.longEnough);
442
+ const spansFound = contextFunnel.spansFound + documentFunnel.spansFound;
443
+ const signature = `${spansFound}:${snapshot.terms.length}:${offerable.length}`;
444
+ if (signature === lastLoggedSignature) {
445
+ return;
446
+ }
447
+ lastLoggedSignature = signature;
448
+ ctcTag('harvest', `${trigger} · ${spansFound} inline spans → ${snapshot.terms.length} kept, ${offerable.length} offerable${evictedTerms > 0 ? `, ${evictedTerms} evicted` : ''} · __atlCtcDebug__.harvest() to inspect`, CTC_STYLES.lm);
449
+ if (isAutocompleteDebugVerbose()) {
450
+ // eslint-disable-next-line no-console
451
+ console.table(snapshot.funnels);
452
+ // eslint-disable-next-line no-console
453
+ console.table(snapshot.terms);
454
+ }
455
+ };