@copilotkit/learning 0.0.1 → 1.80.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.
@@ -134,6 +134,7 @@ describe("Trajectory capture contract", () => {
134
134
  const input = document.querySelector("input")!;
135
135
  input.value = "synthetic@example.com";
136
136
  input.dispatchEvent(new Event("input", { bubbles: true }));
137
+ vi.advanceTimersByTime(300);
137
138
  expect(send.mock.calls[0]?.[0].value).toMatchObject({
138
139
  route: "/deals",
139
140
  url: expect.stringContaining("?email="),
@@ -365,3 +366,107 @@ describe("Trajectory capture contract", () => {
365
366
  expect(vi.getTimerCount()).toBe(0);
366
367
  });
367
368
  });
369
+
370
+ it.each(["custom", "click", "navigation", "stop", "pagehide"])(
371
+ "flushes pending text before %s",
372
+ (next) => {
373
+ const { collector, send } = setup();
374
+ document.body.innerHTML = '<input id="notes"><button>Approve</button>';
375
+ collector.start();
376
+ const input = document.querySelector("input")!;
377
+ input.value = "final notes";
378
+ input.dispatchEvent(new Event("input", { bubbles: true }));
379
+ expect(send.mock.calls.map(([event]) => event.name)).toEqual(["page"]);
380
+ if (next === "custom") collector.emit("order.approved", { total: 480 });
381
+ else if (next === "click") document.querySelector("button")!.click();
382
+ else if (next === "navigation") history.pushState(null, "", "/next");
383
+ else if (next === "pagehide") window.dispatchEvent(new Event("pagehide"));
384
+ else collector.stop();
385
+ const events = send.mock.calls.map(([event]) => event);
386
+ expect(events.map((event) => event.name)).toEqual([
387
+ "page",
388
+ "input",
389
+ ...(next === "custom"
390
+ ? ["order.approved"]
391
+ : next === "click" || next === "navigation"
392
+ ? [next]
393
+ : []),
394
+ ]);
395
+ expect(events[1]?.value).toMatchObject({
396
+ route: "/deals",
397
+ target: { value: "final notes" },
398
+ });
399
+ collector.stop();
400
+ vi.advanceTimersByTime(1000);
401
+ expect(send).toHaveBeenCalledTimes(events.length);
402
+ },
403
+ );
404
+
405
+ it("network capture does not flush or delay pending text in the trajectory collector", async () => {
406
+ const originalFetch = globalThis.fetch;
407
+ globalThis.fetch = vi.fn(async () => new Response(null, { status: 204 }));
408
+ const { collector, send } = setup();
409
+ document.body.innerHTML = "<input>";
410
+ try {
411
+ collector.start();
412
+ const input = document.querySelector("input")!;
413
+ input.value = "pending search";
414
+ input.dispatchEvent(new Event("input", { bubbles: true }));
415
+ await vi.advanceTimersByTimeAsync(100);
416
+
417
+ await fetch("/typeahead?q=pending");
418
+ await vi.advanceTimersByTimeAsync(0);
419
+
420
+ expect(send.mock.calls.map(([event]) => event.name)).toEqual([
421
+ "page",
422
+ "network",
423
+ ]);
424
+ await vi.advanceTimersByTimeAsync(199);
425
+ expect(send.mock.calls.map(([event]) => event.name)).toEqual([
426
+ "page",
427
+ "network",
428
+ ]);
429
+ await vi.advanceTimersByTimeAsync(1);
430
+ expect(send.mock.calls.map(([event]) => event.name)).toEqual([
431
+ "page",
432
+ "network",
433
+ "input",
434
+ ]);
435
+ expect(send.mock.calls[2]?.[0]).toMatchObject({
436
+ timestamp: NOW + 300,
437
+ value: { target: { value: "pending search" } },
438
+ });
439
+ } finally {
440
+ collector.stop();
441
+ globalThis.fetch = originalFetch;
442
+ }
443
+ });
444
+
445
+ it.each(["emit", "stop"])(
446
+ "a beforeSend stop/restart during %s cannot carry an old event into the new capture",
447
+ (action) => {
448
+ const { collector, send } = setup({
449
+ beforeSend(event) {
450
+ if (event.name === "input") {
451
+ collector.stop();
452
+ collector.start();
453
+ }
454
+ return event;
455
+ },
456
+ });
457
+ document.body.innerHTML = "<input>";
458
+ collector.start();
459
+ document.querySelector("input")!.value = "pending";
460
+ document
461
+ .querySelector("input")!
462
+ .dispatchEvent(new Event("input", { bubbles: true }));
463
+ if (action === "emit") collector.emit("old.action", {});
464
+ else collector.stop();
465
+ expect(send.mock.calls.map(([event]) => event.name)).toEqual([
466
+ "page",
467
+ "page",
468
+ ]);
469
+ collector.emit("new.action", {});
470
+ expect(send.mock.calls.at(-1)?.[0].name).toBe("new.action");
471
+ },
472
+ );
package/src/clicks.ts CHANGED
@@ -66,6 +66,298 @@ export function describeTarget(el: Element, redact: Redactor) {
66
66
  return described;
67
67
  }
68
68
 
69
+ const MAX_LABEL_LENGTH = 80;
70
+ // The text walk stops past these bounds. For a control's own text, that means no name.
71
+ const MAX_WALK_NODES = 1000;
72
+ const MAX_WALK_CHARS = 4 * MAX_LABEL_LENGTH;
73
+ // Controls whose name comes from their content. An explicit role overrides the
74
+ // tag, so `<li role="option">` qualifies and `<a href role="row">` does not.
75
+ const CONTROL_ROLE =
76
+ /^(button|link|tab|menuitem(checkbox|radio)?|option|checkbox|switch|radio|treeitem)$/;
77
+ const CONTENTEDITABLE = '[contenteditable]:not([contenteditable="false"])';
78
+ const TEXT_ENTRY = `${CONTENTEDITABLE}, [role~=textbox], [role~=searchbox], [role~=combobox]`;
79
+ const EDITABLE = `input, textarea, select, ${TEXT_ENTRY}`;
80
+ // Card-like structure: a control containing it gets no name from its text,
81
+ // except for one lone paragraph, or headings inside `summary`.
82
+ const HEADING = "h1, h2, h3, h4, h5, h6, [role~=heading]";
83
+ const STRUCTURE = `${HEADING}, p, ul, ol, li, dl, table, article, section, blockquote`;
84
+ const SKIPPED = 'script, style, template, [hidden], [aria-hidden="true"]';
85
+ // Inline elements that do not separate words.
86
+ const PHRASING =
87
+ /^(b|i|u|em|strong|mark|small|sub|sup|abbr|code|kbd|s|q|cite|dfn|var|time|data|bdi|bdo|wbr|span)$/;
88
+ const CAPTION_INPUT = /^(submit|button|reset|image)$/;
89
+ // Input types that do not hold typed text.
90
+ const NON_TEXT_INPUT =
91
+ /^(submit|button|reset|image|checkbox|radio|range|color|file|hidden)$/;
92
+ // Includes bidi controls, zero-width characters, soft hyphens, word joiners,
93
+ // variation selectors, and tag characters.
94
+ const IGNORABLE = /\p{Default_Ignorable_Code_Point}/gu;
95
+ // Quantifier bounds and run-start anchors keep each pattern linear. No
96
+ // lookbehind: a regex literal with one fails to parse in older Safari.
97
+ // A URL ends before trailing sentence punctuation.
98
+ const URL_LIKE =
99
+ /(?:\b[a-z][\w+.-]{0,31}:\/\/|\bwww\.)[^\s<>"']*[^\s<>"'.,;:!?)]/gi;
100
+ const EMAIL =
101
+ /[\p{L}\p{N}_.%+-]{1,64}@[\p{L}\p{N}-]{1,63}(?:\.[\p{L}\p{N}-]{1,63})+/gu;
102
+ // JWTs, and three dotted segments that are each long or contain a digit, but
103
+ // not dotted names such as `settings.notifications.preferences`.
104
+ const JWT =
105
+ /(^|[^\w-])(?:eyJ[\w-]*\.[\w-]*\.[\w-]*|[\w-]{16,}\.[\w-]{16,}\.[\w-]{16,}|(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,})/g;
106
+ const TOKEN_RUN = /[\w+/=-]{24,}/g;
107
+
108
+ const roleOf = (element: Element) =>
109
+ (element.getAttribute("role") ?? "").trim().split(/\s+/)[0]!.toLowerCase();
110
+
111
+ const idRefs = (element: Element, name: string) =>
112
+ (element.getAttribute(name) ?? "").split(/\s+/).filter(Boolean);
113
+
114
+ const rootOf = (element: Element) => {
115
+ const root = element.getRootNode();
116
+ return root instanceof Document || root instanceof ShadowRoot ? root : null;
117
+ };
118
+
119
+ /** Buttons, links with `href`, `summary`, and elements with a control role. */
120
+ const isControl = (element: Element) => {
121
+ const role = roleOf(element);
122
+ return role
123
+ ? CONTROL_ROLE.test(role)
124
+ : element.matches("button, a[href], summary");
125
+ };
126
+
127
+ /** Text-like inputs, text areas, selects, and anything inside editable content or a text-entry role. */
128
+ const holdsValue = (element: Element) =>
129
+ element instanceof HTMLInputElement
130
+ ? !NON_TEXT_INPUT.test(element.type)
131
+ : element.matches("textarea, select") ||
132
+ element.closest(TEXT_ENTRY) !== null;
133
+
134
+ /**
135
+ * The name an image contributes: `alt` of an `img`, or `aria-label` (else a
136
+ * plain `<title>`) of an `svg` or `role="img"` element. `undefined` when the
137
+ * element is not an image.
138
+ */
139
+ function imageName(element: Element) {
140
+ if (element.localName === "img") return element.getAttribute("alt");
141
+ if (element.localName !== "svg" && roleOf(element) !== "img")
142
+ return undefined;
143
+ const title = Array.from(element.children).find(
144
+ (child) => child.localName === "title" && child.childElementCount === 0,
145
+ );
146
+ return element.getAttribute("aria-label") ?? title?.textContent ?? null;
147
+ }
148
+
149
+ /**
150
+ * Rendered text. Inline formatting joins words; other elements separate them.
151
+ * Skips hidden subtrees, uses image names, and does
152
+ * not walk into images. Yields nothing for editable content or editable
153
+ * descendants. In `strict` mode (a control's own text) it also yields nothing
154
+ * past the walk bounds, for card-like structure, and for text that looks like
155
+ * separate fields: two `div`s that directly hold text, or three sibling
156
+ * elements that each carry text.
157
+ */
158
+ function safeText(root: Element, strict: boolean) {
159
+ if (holdsValue(root)) return "";
160
+ let text = "";
161
+ let nodes = 0;
162
+ let paragraphs = 0;
163
+ // The first div that holds text; a second one means separate fields.
164
+ let div: Element | undefined;
165
+ const bearers = new Map<Node, Set<Node>>();
166
+ const add = (part: string) => (text = (text + part).replace(/\s+/g, " "));
167
+ // Marks `start` and its ancestors below root as carrying text. False once
168
+ // three siblings do.
169
+ const bear = (start: Node) => {
170
+ if (!strict) return true;
171
+ for (
172
+ let child = start, parent = start.parentNode;
173
+ child !== root && parent !== null;
174
+ child = parent, parent = parent.parentNode
175
+ ) {
176
+ const siblings = bearers.get(parent) ?? new Set<Node>();
177
+ bearers.set(parent, siblings);
178
+ if (siblings.has(child)) break;
179
+ if (siblings.add(child).size > 2) return false;
180
+ }
181
+ return true;
182
+ };
183
+ let node: Node | null = root.firstChild;
184
+ while (node !== null) {
185
+ if (++nodes > MAX_WALK_NODES || text.length > MAX_WALK_CHARS) {
186
+ if (strict) return "";
187
+ break;
188
+ }
189
+ let descend = false;
190
+ if (node instanceof Text) {
191
+ const parent = node.parentNode as Element;
192
+ // The block that holds this text, through inline elements.
193
+ let holder = parent;
194
+ while (holder !== root && PHRASING.test(holder.localName))
195
+ holder = holder.parentElement!;
196
+ if (
197
+ /\S/.test(node.data) &&
198
+ (!bear(parent) ||
199
+ (strict && holder.localName === "div" && (div ??= holder) !== holder))
200
+ )
201
+ return "";
202
+ add(node.data);
203
+ } else if (node instanceof Element) {
204
+ if (node.matches(EDITABLE)) return "";
205
+ if (
206
+ strict &&
207
+ node.matches(STRUCTURE) &&
208
+ !(root.localName === "summary" && node.matches(HEADING)) &&
209
+ (node.localName !== "p" || ++paragraphs > 1)
210
+ )
211
+ return "";
212
+ const name = node.matches(SKIPPED) ? null : imageName(node);
213
+ if (name) {
214
+ if (!bear(node)) return "";
215
+ add(` ${name} `);
216
+ } else if (name === undefined) {
217
+ if (!PHRASING.test(node.localName)) add(" ");
218
+ descend = true;
219
+ }
220
+ }
221
+ if (descend && node.firstChild !== null) node = node.firstChild;
222
+ else {
223
+ while (node !== root && node.nextSibling === null) {
224
+ node = node.parentNode!;
225
+ if (!PHRASING.test((node as Element).localName)) add(" ");
226
+ }
227
+ node = node === root ? null : node.nextSibling;
228
+ }
229
+ }
230
+ return text;
231
+ }
232
+
233
+ /** An option or tree item of an autocomplete or search popup, whose text usually repeats what the user typed. */
234
+ const isSuggestion = (element: Element) =>
235
+ (element.localName === "option" ||
236
+ /^(option|treeitem)$/.test(roleOf(element))) &&
237
+ inSuggestionList(element);
238
+
239
+ /** Whether `element` is, or is inside, a combobox, a datalist, or the popup a combobox or autocomplete field controls or owns. */
240
+ function inSuggestionList(element: Element) {
241
+ if (element.closest("[role~=combobox], datalist") !== null) return true;
242
+ const root = rootOf(element);
243
+ return (
244
+ root !== null &&
245
+ Array.from(
246
+ root.querySelectorAll("[role~=combobox], [aria-autocomplete]"),
247
+ ).some((owner) =>
248
+ [...idRefs(owner, "aria-controls"), ...idRefs(owner, "aria-owns")].some(
249
+ (id) => root.getElementById(id)?.contains(element),
250
+ ),
251
+ )
252
+ );
253
+ }
254
+
255
+ function labelledBy(element: Element) {
256
+ const root = rootOf(element);
257
+ return root === null
258
+ ? null
259
+ : idRefs(element, "aria-labelledby")
260
+ .map((id) => {
261
+ const referenced = root.getElementById(id);
262
+ return referenced === null || inSuggestionList(referenced)
263
+ ? ""
264
+ : safeText(referenced, false);
265
+ })
266
+ .join(" ");
267
+ }
268
+
269
+ /** A control's own text, unless it is longer than a label: then it is content, not a name. */
270
+ const normalize = (text: string) =>
271
+ text.replace(IGNORABLE, "").replace(/\s+/g, " ").trim();
272
+
273
+ function contentText(element: Element) {
274
+ const text = safeText(element, true);
275
+ return Array.from(normalize(text)).length > MAX_LABEL_LENGTH ? "" : text;
276
+ }
277
+
278
+ /** Removes invisible characters, credentials, emails, URLs, and tokens, then caps the length. */
279
+ function cleanLabel(raw: string, redact: Redactor) {
280
+ const chars = Array.from(
281
+ // Scrub again after collapsing, also for remembered values that differ
282
+ // in whitespace or invisible characters.
283
+ redact
284
+ .scrub(
285
+ normalize(redact.body(raw.replace(IGNORABLE, "")) ?? ""),
286
+ normalize,
287
+ )
288
+ .replace(URL_LIKE, "[url]")
289
+ .replace(EMAIL, "[email]")
290
+ .replace(JWT, `$1${REDACTED}`)
291
+ .replace(TOKEN_RUN, (run) => (/\d/.test(run) ? REDACTED : run)),
292
+ );
293
+ return chars.length > MAX_LABEL_LENGTH
294
+ ? `${chars
295
+ .slice(0, MAX_LABEL_LENGTH - 1)
296
+ .join("")
297
+ .trimEnd()}…`
298
+ : chars.join("");
299
+ }
300
+
301
+ function firstLabel(sources: (() => string | null)[], redact: Redactor) {
302
+ for (const source of sources) {
303
+ const label = cleanLabel(source() ?? "", redact);
304
+ if (label) return label;
305
+ }
306
+ return undefined;
307
+ }
308
+
309
+ /**
310
+ * A short name for the clicked control, from the first source that yields one:
311
+ * `aria-labelledby`, `aria-label`, an input caption or `alt`, `title` (controls
312
+ * only), then the text of a control named by its content. Containers, cards,
313
+ * and editable content never contribute their text. An autocomplete suggestion,
314
+ * or anything inside one, gets no label from any source; `data-copilotkit-action`
315
+ * names it instead. An icon without its own name takes the name of the control
316
+ * around it.
317
+ */
318
+ function clickLabel(element: Element, redact: Redactor): string | undefined {
319
+ const option = element.closest("option, [role~=option], [role~=treeitem]");
320
+ if (option !== null && isSuggestion(option)) return undefined;
321
+ // aria-labelledby and aria-label apply to any other target.
322
+ const named = [
323
+ () => labelledBy(element),
324
+ () => element.getAttribute("aria-label"),
325
+ ];
326
+ if (
327
+ /^(img|svg)$/.test(element.localName) ||
328
+ /^(img|presentation|none)$/.test(roleOf(element))
329
+ ) {
330
+ const own = firstLabel(
331
+ [...named, () => imageName(element) ?? null],
332
+ redact,
333
+ );
334
+ if (own !== undefined) return own;
335
+ // parentElement is null at a shadow root, so the search stays inside it.
336
+ let control = element.parentElement;
337
+ while (control !== null && !isControl(control))
338
+ control = control.parentElement;
339
+ return control === null ? undefined : clickLabel(control, redact);
340
+ }
341
+ const control = isControl(element) && !holdsValue(element);
342
+ const captionInput =
343
+ element instanceof HTMLInputElement && CAPTION_INPUT.test(element.type);
344
+ return firstLabel(
345
+ [
346
+ ...named,
347
+ // The caption of a button-like input, or the `alt` of an image input.
348
+ () =>
349
+ captionInput
350
+ ? element.type === "image"
351
+ ? element.getAttribute("alt")
352
+ : element.value
353
+ : null,
354
+ () => (control || captionInput ? element.getAttribute("title") : null),
355
+ () => (control ? contentText(element) : null),
356
+ ],
357
+ redact,
358
+ );
359
+ }
360
+
69
361
  /** Snapshot live control properties; HTML attributes do not reflect edited values. */
70
362
  export function readControlValue(element: Element, redact: Redactor) {
71
363
  if (element instanceof HTMLInputElement) {
@@ -96,7 +388,7 @@ export function readControlValue(element: Element, redact: Redactor) {
96
388
  };
97
389
  }
98
390
  if (element instanceof HTMLTextAreaElement) return { value: element.value };
99
- if (element.matches('[contenteditable]:not([contenteditable="false"])')) {
391
+ if (element.matches(CONTENTEDITABLE)) {
100
392
  return { value: element.textContent ?? "" };
101
393
  }
102
394
  return {};
@@ -140,12 +432,25 @@ export function installClickCapture(params: {
140
432
  } = params;
141
433
  let origin: PointerOrigin | null = null;
142
434
 
435
+ // Input events share describeTarget; only clicks carry a label.
436
+ const describeClick = (el: Element) => {
437
+ const described = describeTarget(el, redact);
438
+ if (described === null) return null;
439
+ try {
440
+ const label = clickLabel(described.element, redact);
441
+ if (label !== undefined) described.target.label = label;
442
+ } catch {
443
+ // A failed label must not drop the click.
444
+ }
445
+ return described;
446
+ };
447
+
143
448
  const onPointerDown = (event: PointerEvent) => {
144
449
  try {
145
450
  if (event.isPrimary === false) return;
146
451
  const el = eventElement(event);
147
452
  origin = {
148
- described: el === null ? null : describeTarget(el, redact),
453
+ described: el === null ? null : describeClick(el),
149
454
  x: event.clientX,
150
455
  y: event.clientY,
151
456
  };
@@ -167,7 +472,7 @@ export function installClickCapture(params: {
167
472
  isNear(pointerOrigin, event);
168
473
  const described = canRecover
169
474
  ? pointerOrigin.described
170
- : describeTarget(el, redact);
475
+ : describeClick(el);
171
476
  if (described === null) return;
172
477
  const input = event.detail === 0 ? "keyboard" : "pointer";
173
478
  emit("click", {
package/src/collector.ts CHANGED
@@ -27,6 +27,7 @@ interface Session {
27
27
  dropped: number;
28
28
  uninstalls: (() => void)[];
29
29
  timer: ReturnType<typeof setInterval> | undefined;
30
+ flushInputs?: () => void;
30
31
  }
31
32
 
32
33
  /**
@@ -79,6 +80,8 @@ export function createCollector(options: CollectorOptions) {
79
80
  const record = (name: string, value: Record<string, unknown>) => {
80
81
  const current = session;
81
82
  if (current === null) return;
83
+ if (name !== "input" && name !== "network") current.flushInputs?.();
84
+ if (session !== current) return;
82
85
  try {
83
86
  seq += 1;
84
87
  const event: LearningEvent = {
@@ -88,7 +91,7 @@ export function createCollector(options: CollectorOptions) {
88
91
  timestamp: Date.now(),
89
92
  };
90
93
  const kept = beforeSend === undefined ? event : beforeSend(event);
91
- if (kept === null) return;
94
+ if (kept === null || session !== current) return;
92
95
  current.queue.push(kept);
93
96
  if (current.queue.length >= FLUSH_SIZE) flush();
94
97
  } catch {
@@ -146,10 +149,11 @@ export function createCollector(options: CollectorOptions) {
146
149
  redact,
147
150
  }),
148
151
  );
149
- if (capture.inputs)
150
- current.uninstalls.push(
151
- installInputCapture({ emit: recordCurrent, redact }),
152
- );
152
+ if (capture.inputs) {
153
+ const inputs = installInputCapture({ emit: recordCurrent, redact });
154
+ current.flushInputs = inputs.flush;
155
+ current.uninstalls.push(inputs);
156
+ }
153
157
  const onPageHide = () => flush({ beacon: true });
154
158
  window.addEventListener("pagehide", onPageHide);
155
159
  current.uninstalls.push(() =>
@@ -167,6 +171,8 @@ export function createCollector(options: CollectorOptions) {
167
171
  const stop = () => {
168
172
  const current = session;
169
173
  if (current === null) return;
174
+ current.flushInputs?.();
175
+ if (session !== current) return;
170
176
  clearInterval(current.timer);
171
177
  for (const uninstall of current.uninstalls) uninstall();
172
178
  flush();