@atlaskit/editor-plugin-autocomplete 9.0.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,9 +26,29 @@ declare global {
26
26
  * `__atlCtcDebug__.enable('verbose')`.
27
27
  */
28
28
  enable: (level?: 'verbose') => void;
29
+ /**
30
+ * Snapshot of the inline-code surfaces harvested this session, or what a
31
+ * typed prefix would be offered: `__atlCtcDebug__.harvest('ml-s')`.
32
+ *
33
+ * Installed by the harvester rather than declared with the rest of the
34
+ * API, and typed as `unknown` so the return shape can live with the module
35
+ * that owns it instead of creating a cycle back to this one. Undefined
36
+ * until the harvester chunk has loaded.
37
+ */
38
+ harvest?: (typedPrefix?: string) => unknown;
29
39
  isEnabled: () => boolean;
30
40
  /** Whether verbose logging (candidate tables + extra detail) is on. */
31
41
  isVerbose: () => boolean;
42
+ /**
43
+ * Snapshot of the L1 session boosts — the vocabulary words this session
44
+ * has seen and how often — or one family of them:
45
+ * `__atlCtcDebug__.session('poll')`.
46
+ *
47
+ * Installed by the predictor for the same reasons as `harvest` above, and
48
+ * distinct from it: this reports known words whose frequency the session
49
+ * raised, while `harvest` holds surfaces the vocabulary does not have.
50
+ */
51
+ session?: (prefix?: string) => unknown;
32
52
  };
33
53
  }
34
54
  }
@@ -120,7 +140,7 @@ const printLegend = (verbose: boolean): void => {
120
140
  );
121
141
  // eslint-disable-next-line no-console
122
142
  console.log(
123
- '%cInspect%c __atlCtcDebug__.enable("verbose") · .disable()',
143
+ '%cInspect%c __atlCtcDebug__.enable("verbose") · .disable() · .harvest() (session inline code) · .session() (L1 boosts) — both narrow to a prefix, e.g. .session("poll")',
124
144
  CTC_STYLES.section,
125
145
  CTC_STYLES.body,
126
146
  );
@@ -163,5 +183,31 @@ export const isAutocompleteDebugEnabled = (): boolean => getDebugApi()?.isEnable
163
183
 
164
184
  export const isAutocompleteDebugVerbose = (): boolean => getDebugApi()?.isVerbose() ?? false;
165
185
 
186
+ /**
187
+ * Hang the harvest snapshot off the console API.
188
+ *
189
+ * Unlike the log helpers this is available whether or not debug is enabled:
190
+ * inspecting state on demand is not logging, and asking someone to turn on
191
+ * logging and retype to find out what the session already holds defeats the
192
+ * point of being able to ask.
193
+ */
194
+ export const registerCtcHarvestInspector = (inspect: (typedPrefix?: string) => unknown): void => {
195
+ const api = getDebugApi();
196
+ if (api) {
197
+ api.harvest = inspect;
198
+ }
199
+ };
200
+
201
+ /**
202
+ * Hang the L1 session-boost snapshot off the console API, on the same terms as
203
+ * `registerCtcHarvestInspector`: available whether or not logging is on.
204
+ */
205
+ export const registerCtcSessionInspector = (inspect: (prefix?: string) => unknown): void => {
206
+ const api = getDebugApi();
207
+ if (api) {
208
+ api.session = inspect;
209
+ }
210
+ };
211
+
166
212
  // Eagerly install so the console API is available on load, regardless of call order.
167
213
  getDebugApi();
@@ -0,0 +1,561 @@
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 type { Node as PMNode } from '@atlaskit/editor-prosemirror/model';
33
+
34
+ import {
35
+ CTC_STYLES,
36
+ ctcTag,
37
+ isAutocompleteDebugEnabled,
38
+ isAutocompleteDebugVerbose,
39
+ registerCtcHarvestInspector,
40
+ } from './debug-mode';
41
+ import { lookupVocabularySource } from './text-predictor';
42
+
43
+ /** Longer than this is a command or a path, not a term anyone wants completed. */
44
+ const MAX_IDENTIFIER_LENGTH = 50;
45
+
46
+ /**
47
+ * Longer than the predictor's DISPLAY_MIN_PREFIX_LENGTH of 3.
48
+ *
49
+ * The scored path can afford three characters because it has a posterior and a
50
+ * winner margin to answer for the fourth. This path has neither, so it asks the
51
+ * user to commit further before it will guess.
52
+ */
53
+ const MIN_TYPED_PREFIX_LENGTH = 4;
54
+
55
+ /** Mirrors the predictor's MIN_SUGGESTION_LENGTH. */
56
+ const MIN_GHOST_LENGTH = 3;
57
+
58
+ /**
59
+ * Sightings needed before a surface may be offered, counting both sources.
60
+ *
61
+ * One, because asking for two reads the replies this exists for as no evidence
62
+ * at all: a reply answering "what are our service names" names each one exactly
63
+ * once, and ten terms mentioned once each is the normal shape of the answer, not
64
+ * a weak signal. What keeps noise out is not a count — see `isDisplayEligible`.
65
+ */
66
+ const MIN_OCCURRENCES = 1;
67
+
68
+ /**
69
+ * Cap on the harvested set.
70
+ *
71
+ * Not a memory bound — surfaces are capped at 50 characters. It is there so a
72
+ * long session's early terms cannot sit in the set competing with what is being
73
+ * discussed now, and so the display path's scan stays bounded. Provisional:
74
+ * size it off the funnel numbers from a branch deploy rather than this guess.
75
+ */
76
+ const MAX_HARVESTED_TERMS = 200;
77
+
78
+ /**
79
+ * A surface shorter than a qualifying prefix plus a qualifying ghost can never
80
+ * be shown. Recorded rather than filtered, so the funnel shows how much of the
81
+ * harvest is unusable for that reason alone.
82
+ */
83
+ const DISPLAYABLE_MIN_LENGTH = MIN_TYPED_PREFIX_LENGTH + MIN_GHOST_LENGTH;
84
+
85
+ const FENCED_REGION_REGEX = /```[\s\S]*?```/gu;
86
+ const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/gu;
87
+ const WHITESPACE_REGEX = /\s/u;
88
+ const STARTS_WITH_LETTER_REGEX = /^\p{L}/u;
89
+
90
+ interface HarvestedTerm {
91
+ /** Whether the surface is long enough to ever produce a ghost. */
92
+ displayable: boolean;
93
+ /**
94
+ * Code-marked spans in the live document as of the most recent walk.
95
+ *
96
+ * A snapshot, not a running total. The walk re-reads the whole document on
97
+ * every word boundary, so accumulating here would count keystrokes rather
98
+ * than mentions — and it would count them faster the longer the surface is.
99
+ * Replacing the value also means deleting the span withdraws the evidence,
100
+ * which is the behaviour a snapshot should have.
101
+ */
102
+ documentSpans: number;
103
+ /** When this surface was last seen in either source; breaks eviction ties. */
104
+ lastSeenAt: number;
105
+ /**
106
+ * Spans across ingested reply and page text.
107
+ *
108
+ * Accumulating is safe here in a way it is not for the document: the plugin
109
+ * dedupes ingested text before it reaches this module, so each mention is
110
+ * counted once. Near-duplicate page content re-ingested under a small edit
111
+ * is the remaining way to over-count, and it inflates by ones.
112
+ */
113
+ replyOccurrences: number;
114
+ /** Original casing, first occurrence wins — casing is load-bearing in code. */
115
+ surface: string;
116
+ }
117
+
118
+ /** Why this surface was chosen over the others that completed the prefix. */
119
+ export type HarvestCollapseRule = 'alphabetical' | 'sightings' | 'sole-match';
120
+
121
+ export interface HarvestMatch {
122
+ /** Which rule settled the prefix; `sole-match` when there was no rival. */
123
+ collapsedBy: HarvestCollapseRule;
124
+ /** Code-marked spans in the document, as of the last walk. */
125
+ documentSpans: number;
126
+ /** Characters of the surface the user has not typed yet. */
127
+ ghostText: string;
128
+ /** Mentions across ingested reply and page text. */
129
+ replyOccurrences: number;
130
+ /** Surfaces that also completed the prefix and lost, in the losing order. */
131
+ rivalSurfaces: string[];
132
+ /** Full surface in its original casing, which replaces the typed prefix. */
133
+ surface: string;
134
+ /** Length of the typed prefix the surface replaces on accept. */
135
+ typedPrefixLength: number;
136
+ }
137
+
138
+ interface HarvestFunnel {
139
+ accepted: number;
140
+ rejectedInFence: number;
141
+ rejectedKnownL2: number;
142
+ rejectedKnownL3: number;
143
+ rejectedNonAlphaStart: number;
144
+ rejectedTooLong: number;
145
+ rejectedWhitespace: number;
146
+ spansFound: number;
147
+ }
148
+
149
+ const emptyFunnel = (): HarvestFunnel => ({
150
+ accepted: 0,
151
+ rejectedInFence: 0,
152
+ rejectedKnownL2: 0,
153
+ rejectedKnownL3: 0,
154
+ rejectedNonAlphaStart: 0,
155
+ rejectedTooLong: 0,
156
+ rejectedWhitespace: 0,
157
+ spansFound: 0,
158
+ });
159
+
160
+ // Session-scoped, and only correct because the plugin resets it on destroy: the
161
+ // map holds user and assistant content, so surviving an unmount would leak one
162
+ // conversation's terms into the next.
163
+ const harvested = new Map<string, HarvestedTerm>();
164
+ // Two funnels because the two sources are read on different schedules. Ingested
165
+ // text is seen once, so its funnel accumulates; the document is re-walked on
166
+ // every word boundary, so its funnel is replaced to stay a description of the
167
+ // document rather than of how long the session has run.
168
+ let contextFunnel = emptyFunnel();
169
+ let documentFunnel = emptyFunnel();
170
+ let evictedTerms = 0;
171
+ let lastLoggedSignature = '';
172
+
173
+ const collectFencedRanges = (text: string): Array<[number, number]> => {
174
+ const ranges: Array<[number, number]> = [];
175
+ for (const match of text.matchAll(FENCED_REGION_REGEX)) {
176
+ if (match.index !== undefined) {
177
+ ranges.push([match.index, match.index + match[0].length]);
178
+ }
179
+ }
180
+ return ranges;
181
+ };
182
+
183
+ const isInsideFence = (ranges: Array<[number, number]>, index: number): boolean =>
184
+ ranges.some(([start, end]) => index >= start && index < end);
185
+
186
+ /** The cleaned surface if it survives intake, or null. Records the rejection. */
187
+ const admitCandidate = (raw: string, funnel: HarvestFunnel): string | null => {
188
+ // Markdown permits one space of padding inside the ticks, so trim before
189
+ // testing for the internal whitespace that separates a term from a command.
190
+ const candidate = raw.trim();
191
+ if (candidate.length === 0 || WHITESPACE_REGEX.test(candidate)) {
192
+ funnel.rejectedWhitespace++;
193
+ return null;
194
+ }
195
+ if (candidate.length > MAX_IDENTIFIER_LENGTH) {
196
+ funnel.rejectedTooLong++;
197
+ return null;
198
+ }
199
+ // Flags (`-d`, `--open-url`) match every other rule and are worthless to
200
+ // complete, so the leading character has to be a letter.
201
+ if (!STARTS_WITH_LETTER_REGEX.test(candidate)) {
202
+ funnel.rejectedNonAlphaStart++;
203
+ return null;
204
+ }
205
+ // A surface the scored path can already serve does not belong here. Harvesting
206
+ // it would put a candidate with no frequencies, no vector and no canonical
207
+ // token ids up against one that has all three.
208
+ const known = lookupVocabularySource(candidate);
209
+ if (known === 'l2') {
210
+ funnel.rejectedKnownL2++;
211
+ return null;
212
+ }
213
+ if (known === 'l3') {
214
+ funnel.rejectedKnownL3++;
215
+ return null;
216
+ }
217
+ return candidate;
218
+ };
219
+
220
+ /** Total mentions behind a surface, across both sources. */
221
+ const sightingsOf = (term: HarvestedTerm): number => term.documentSpans + term.replyOccurrences;
222
+
223
+ /**
224
+ * Order two surfaces that are competing for the same typed prefix, best first.
225
+ *
226
+ * Sightings lead, because the number of times the session named a thing is the
227
+ * only quantity this module has that says anything about which one is being
228
+ * discussed. The alphabetical tie-break is arbitrary and chosen for being
229
+ * arbitrary in a stable way: on a tie there is nothing to prefer, and a rule
230
+ * that always resolves the same way means the same prefix produces the same
231
+ * ghost every time rather than one that follows harvest order.
232
+ *
233
+ * Numeric-aware so `model@v2` sorts before `model@v10` — versioned identifiers
234
+ * are common enough in this set to be worth not reading as strings.
235
+ */
236
+ const byCollapseOrder = (a: HarvestedTerm, b: HarvestedTerm): number =>
237
+ sightingsOf(b) - sightingsOf(a) ||
238
+ a.surface.localeCompare(b.surface, 'en', { numeric: true, sensitivity: 'base' });
239
+
240
+ /**
241
+ * Drop the weakest terms until the set is within its cap.
242
+ *
243
+ * Least evidence first, oldest sighting to break the tie. FIFO is the local
244
+ * precedent (MAX_INGESTED_CONTEXT_TEXTS in the plugin) but it is the wrong
245
+ * policy here: the earliest-harvested term may be the one the conversation is
246
+ * actually about, and the sighting count is the only ranking this module has.
247
+ */
248
+ const evictWeakestTerms = (): void => {
249
+ if (harvested.size <= MAX_HARVESTED_TERMS) {
250
+ return;
251
+ }
252
+ const ordered = Array.from(harvested.entries()).sort(
253
+ ([, a], [, b]) => sightingsOf(a) - sightingsOf(b) || a.lastSeenAt - b.lastSeenAt,
254
+ );
255
+ for (const [key] of ordered.slice(0, harvested.size - MAX_HARVESTED_TERMS)) {
256
+ harvested.delete(key);
257
+ evictedTerms++;
258
+ }
259
+ };
260
+
261
+ /** Count each admitted surface once per occurrence within a single pass. */
262
+ const tallySpans = (surfaces: string[]): Map<string, { count: number; surface: string }> => {
263
+ const counts = new Map<string, { count: number; surface: string }>();
264
+ for (const surface of surfaces) {
265
+ const key = surface.toLowerCase();
266
+ const existing = counts.get(key);
267
+ if (existing) {
268
+ existing.count++;
269
+ } else {
270
+ counts.set(key, { count: 1, surface });
271
+ }
272
+ }
273
+ return counts;
274
+ };
275
+
276
+ /**
277
+ * Harvest single-backtick spans from a markdown-ish string. Fenced regions are
278
+ * skipped: they hold commands and file paths, and their contents are whitespace
279
+ * separated anyway.
280
+ */
281
+ export const harvestInlineCodeFromText = (text: string | undefined): void => {
282
+ if (!text) {
283
+ return;
284
+ }
285
+ const fencedRanges = collectFencedRanges(text);
286
+ const admitted: string[] = [];
287
+ for (const match of text.matchAll(INLINE_CODE_SPAN_REGEX)) {
288
+ if (match.index === undefined) {
289
+ continue;
290
+ }
291
+ contextFunnel.spansFound++;
292
+ if (isInsideFence(fencedRanges, match.index)) {
293
+ contextFunnel.rejectedInFence++;
294
+ continue;
295
+ }
296
+ const surface = admitCandidate(match[1], contextFunnel);
297
+ if (surface) {
298
+ admitted.push(surface);
299
+ }
300
+ }
301
+
302
+ const now = performance.now();
303
+ for (const [key, { count, surface }] of tallySpans(admitted)) {
304
+ const existing = harvested.get(key);
305
+ if (existing) {
306
+ existing.replyOccurrences += count;
307
+ existing.lastSeenAt = now;
308
+ continue;
309
+ }
310
+ harvested.set(key, {
311
+ displayable: surface.length >= DISPLAYABLE_MIN_LENGTH,
312
+ documentSpans: 0,
313
+ lastSeenAt: now,
314
+ replyOccurrences: count,
315
+ surface,
316
+ });
317
+ contextFunnel.accepted++;
318
+ }
319
+ evictWeakestTerms();
320
+ };
321
+
322
+ /**
323
+ * Harvest code-marked text from the live document. The backtick input rule fires
324
+ * on the closing tick and removes both delimiters, so a finished span is only
325
+ * findable through its mark. Code blocks are skipped for the same reason fenced
326
+ * regions are.
327
+ *
328
+ * The whole document is re-read, and what it finds replaces the previous
329
+ * document counts rather than adding to them.
330
+ */
331
+ export const harvestInlineCodeFromDoc = (doc: PMNode): void => {
332
+ documentFunnel = emptyFunnel();
333
+ const admitted: string[] = [];
334
+ doc.descendants((node) => {
335
+ if (node.type.name === 'codeBlock') {
336
+ return false;
337
+ }
338
+ if (node.isText && node.text && node.marks.some((mark) => mark.type.name === 'code')) {
339
+ documentFunnel.spansFound++;
340
+ const surface = admitCandidate(node.text, documentFunnel);
341
+ if (surface) {
342
+ admitted.push(surface);
343
+ }
344
+ }
345
+ return true;
346
+ });
347
+
348
+ const spans = tallySpans(admitted);
349
+ // Surfaces the document no longer holds lose their document evidence. They
350
+ // stay in the set on whatever reply evidence they have.
351
+ for (const [key, term] of harvested) {
352
+ if (!spans.has(key)) {
353
+ term.documentSpans = 0;
354
+ }
355
+ }
356
+ const now = performance.now();
357
+ for (const [key, { count, surface }] of spans) {
358
+ const existing = harvested.get(key);
359
+ if (existing) {
360
+ existing.documentSpans = count;
361
+ existing.lastSeenAt = now;
362
+ continue;
363
+ }
364
+ harvested.set(key, {
365
+ displayable: surface.length >= DISPLAYABLE_MIN_LENGTH,
366
+ documentSpans: count,
367
+ lastSeenAt: now,
368
+ replyOccurrences: 0,
369
+ surface,
370
+ });
371
+ documentFunnel.accepted++;
372
+ }
373
+ evictWeakestTerms();
374
+ };
375
+
376
+ /**
377
+ * Whether a surface still has a sighting behind it.
378
+ *
379
+ * Recurrence turned out to be the wrong thing to lean on. What separates a term
380
+ * worth offering from a word in prose is that someone marked it as code — the
381
+ * author with a code mark, or the model with backticks — and that is already
382
+ * true of everything in this set. The rules that keep noise out are elsewhere:
383
+ * the surface must be absent from L2 and L3, unique among harvested terms for
384
+ * the typed prefix, and on a prefix the scored path has no claim to.
385
+ *
386
+ * The case this still rejects is a withdrawn sighting: a document span the
387
+ * author deleted drops back to zero, and a term with no reply mention behind it
388
+ * stops being offered.
389
+ */
390
+ const isDisplayEligible = (term: HarvestedTerm): boolean =>
391
+ term.documentSpans + term.replyOccurrences >= MIN_OCCURRENCES;
392
+
393
+ /**
394
+ * Every harvested surface that could be shown for `typedPrefix`, best first.
395
+ *
396
+ * A surface whose remaining tail is too short to render is not a rival, it is a
397
+ * surface the user has finished typing. Neither is one whose sighting has been
398
+ * withdrawn. Both used to be counted as ambiguity and used to silence the path.
399
+ */
400
+ const collectCandidates = (typedPrefix: string): HarvestedTerm[] => {
401
+ const needle = typedPrefix.toLowerCase();
402
+ const candidates: HarvestedTerm[] = [];
403
+ for (const term of harvested.values()) {
404
+ if (!term.surface.toLowerCase().startsWith(needle)) {
405
+ continue;
406
+ }
407
+ if (term.surface.length - typedPrefix.length < MIN_GHOST_LENGTH) {
408
+ continue;
409
+ }
410
+ if (!isDisplayEligible(term)) {
411
+ continue;
412
+ }
413
+ candidates.push(term);
414
+ }
415
+ return candidates.sort(byCollapseOrder);
416
+ };
417
+
418
+ /**
419
+ * The harvested surface to offer for `typedPrefix`, or null.
420
+ *
421
+ * Rivals are collapsed rather than treated as a reason to stay quiet. Silence
422
+ * was the original rule, on the grounds that two session surfaces sharing a
423
+ * prefix have nothing to separate them, and it was wrong for the case this path
424
+ * exists to serve: a reply that answers "what are our services" names ten of
425
+ * them, several share a stem, and abstaining meant the harvester went quiet on
426
+ * exactly the reply it was built for. Being wrong here costs one keystroke —
427
+ * the ghost is ignored and typing continues — and staying silent costs the
428
+ * feature.
429
+ *
430
+ * `collapsedBy` records which rule settled it so a surprising ghost can be
431
+ * explained after the fact rather than guessed at.
432
+ */
433
+ export const findHarvestedCompletion = (typedPrefix: string): HarvestMatch | null => {
434
+ if (typedPrefix.length < MIN_TYPED_PREFIX_LENGTH) {
435
+ return null;
436
+ }
437
+ const [winner, runnerUp, ...rest] = collectCandidates(typedPrefix);
438
+ if (!winner) {
439
+ return null;
440
+ }
441
+ return {
442
+ collapsedBy: !runnerUp
443
+ ? 'sole-match'
444
+ : sightingsOf(winner) > sightingsOf(runnerUp)
445
+ ? 'sightings'
446
+ : 'alphabetical',
447
+ documentSpans: winner.documentSpans,
448
+ ghostText: winner.surface.slice(typedPrefix.length),
449
+ replyOccurrences: winner.replyOccurrences,
450
+ rivalSurfaces: (runnerUp ? [runnerUp, ...rest] : []).map((term) => term.surface),
451
+ surface: winner.surface,
452
+ typedPrefixLength: typedPrefix.length,
453
+ };
454
+ };
455
+
456
+ interface HarvestTermSnapshot {
457
+ /** Code-marked spans in the document as of the last walk. */
458
+ documentSpans: number;
459
+ /** Whether the surface is long enough to ever produce a ghost. */
460
+ longEnough: boolean;
461
+ /** Whether a sighting still stands behind it. */
462
+ offerable: boolean;
463
+ /** Mentions across ingested reply and page text. */
464
+ replyOccurrences: number;
465
+ /** Sum of both sources, which is what collapses rivals. */
466
+ sightings: number;
467
+ surface: string;
468
+ }
469
+
470
+ export interface HarvestSnapshot {
471
+ /** Size the set is held to; beyond it the weakest surfaces are dropped. */
472
+ cap: number;
473
+ /** How many surfaces have been dropped to hold the cap this session. */
474
+ evicted: number;
475
+ /**
476
+ * Intake accounting per source. The context funnel accumulates over the
477
+ * session; the live-document funnel describes the most recent walk only.
478
+ */
479
+ funnels: { context: HarvestFunnel; liveDocument: HarvestFunnel };
480
+ /**
481
+ * What a typed prefix would be offered, when one was passed.
482
+ *
483
+ * The harvester's own answer, not a prediction of what will appear on screen:
484
+ * the plugin still has to find the scored path finished and empty on that
485
+ * prefix, and still applies the accept cooldown and the repetition check.
486
+ */
487
+ match?: HarvestMatch | null;
488
+ /** The whole set in collapse order, so the winner for any prefix is above its rivals. */
489
+ terms: HarvestTermSnapshot[];
490
+ }
491
+
492
+ /**
493
+ * Read the session's harvest, optionally asking what `typedPrefix` would get.
494
+ *
495
+ * Installed as `__atlCtcDebug__.harvest()` and returned rather than logged, so
496
+ * the console renders it as an inspectable object and a caller can assert on it.
497
+ */
498
+ export const inspectInlineCodeHarvest = (typedPrefix?: string): HarvestSnapshot => ({
499
+ cap: MAX_HARVESTED_TERMS,
500
+ evicted: evictedTerms,
501
+ funnels: { context: { ...contextFunnel }, liveDocument: { ...documentFunnel } },
502
+ ...(typedPrefix === undefined ? {} : { match: findHarvestedCompletion(typedPrefix) }),
503
+ terms: Array.from(harvested.values())
504
+ .sort(byCollapseOrder)
505
+ .map((term) => ({
506
+ documentSpans: term.documentSpans,
507
+ longEnough: term.displayable,
508
+ offerable: isDisplayEligible(term),
509
+ replyOccurrences: term.replyOccurrences,
510
+ sightings: sightingsOf(term),
511
+ surface: term.surface,
512
+ })),
513
+ });
514
+
515
+ // At module scope so the console API answers before the first keystroke, which
516
+ // is when someone reaching for it usually asks.
517
+ registerCtcHarvestInspector(inspectInlineCodeHarvest);
518
+
519
+ export const resetInlineCodeHarvest = (): void => {
520
+ harvested.clear();
521
+ contextFunnel = emptyFunnel();
522
+ documentFunnel = emptyFunnel();
523
+ evictedTerms = 0;
524
+ lastLoggedSignature = '';
525
+ };
526
+
527
+ /**
528
+ * Print the funnel when it has moved since the last print.
529
+ *
530
+ * Deliberately reports zero-span and all-rejected passes too. Logging only on a
531
+ * successful harvest makes "the replies held no inline code", "every span was
532
+ * filtered out" and "this never ran" indistinguishable, which are the three
533
+ * things worth telling apart.
534
+ */
535
+ export const logInlineCodeHarvest = (trigger: string): void => {
536
+ if (!isAutocompleteDebugEnabled()) {
537
+ return;
538
+ }
539
+ const snapshot = inspectInlineCodeHarvest();
540
+ const offerable = snapshot.terms.filter((term) => term.offerable && term.longEnough);
541
+ const spansFound = contextFunnel.spansFound + documentFunnel.spansFound;
542
+ const signature = `${spansFound}:${snapshot.terms.length}:${offerable.length}`;
543
+ if (signature === lastLoggedSignature) {
544
+ return;
545
+ }
546
+ lastLoggedSignature = signature;
547
+
548
+ ctcTag(
549
+ 'harvest',
550
+ `${trigger} · ${spansFound} inline spans → ${snapshot.terms.length} kept, ${offerable.length} offerable${
551
+ evictedTerms > 0 ? `, ${evictedTerms} evicted` : ''
552
+ } · __atlCtcDebug__.harvest() to inspect`,
553
+ CTC_STYLES.lm,
554
+ );
555
+ if (isAutocompleteDebugVerbose()) {
556
+ // eslint-disable-next-line no-console
557
+ console.table(snapshot.funnels);
558
+ // eslint-disable-next-line no-console
559
+ console.table(snapshot.terms);
560
+ }
561
+ };