@copilotkit/learning 0.0.1 → 1.81.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.
package/README.md CHANGED
@@ -15,6 +15,19 @@ Redaction runs on the first 16 KiB of a body, before the 4 KiB limit applies. Ev
15
15
 
16
16
  Use `capture`, `beforeSend`, `ignoreUrls`, and `data-copilotkit-ignore` for your app's exclusions. The deprecated `routes` option no longer masks or transforms paths.
17
17
 
18
+ Click events also include `target.label`, a short, cleaned name of the clicked control. Automatic Learning shows this name to the model. It comes from the first of `aria-labelledby`, `aria-label`, the caption of a submit, button, reset, or image input, `title` (controls only), and the text of a control named by its content: buttons, links with `href`, `summary`, and roles such as `tab`, `menuitem`, or `option`. Images inside a control count with their `alt`, `aria-label`, or `<title>`, and a clicked icon without its own name takes the name of the control around it. Control text is not used when it looks like separate fields, as cards do: longer than 80 characters, containing headings, lists, tables, fields, or more than one paragraph, or split across two `div`s that hold text, directly or through inline elements, or three text-carrying siblings. ARIA autocomplete suggestions, and anything inside them, get no label from any source: `aria-labelledby`, `aria-label`, `title`, and text are all ignored, and `aria-labelledby` elsewhere that points into a suggestion list gives no name. Use `data-copilotkit-action` to name a suggestion. Containers, editable content, text slotted into web components, and the `<label>` of a native checkbox or radio button (use `aria-label` or `data-copilotkit-action` there) never contribute their text; script, style, template, and hidden content is skipped. Invisible characters are removed, passwords and credentials become `[redacted]`, URLs `[url]`, emails `[email]`, and JWT or other token-like strings `[redacted]`. Other names are cut at 80 characters with `…`, and `label` is omitted when no name remains. Raw `text`, `attributes`, and values are unchanged. `data-copilotkit-action` gives a control an explicit name in `target.action`. To exclude a control or a suggestion list without ARIA roles, add `data-copilotkit-ignore`, or remove the label in `beforeSend`:
19
+
20
+ ```ts
21
+ beforeSend: (event) => {
22
+ if (event.name === "click") delete (event.value.target as { label?: string }).label;
23
+ return event;
24
+ },
25
+ ```
26
+
27
+ See [Click labels](https://docs.copilotkit.ai/intelligence/captured-data#click-labels) for the full rules.
28
+
29
+ Text edits are combined per field and recorded after a 300 ms pause. Committing or leaving the field, pressing Enter outside input-method composition, clicking, navigating, emitting a developer event, or stopping capture records the pending edit first. Paired `input` and `change` notifications for the same edit produce one record; a later user edit can record the same value again. Checkbox and select changes remain immediate. See [Form edit timing](https://docs.copilotkit.ai/intelligence/captured-data#form-edit-timing) for composition and delivery behavior.
30
+
18
31
  ## Authenticated Trajectories
19
32
 
20
33
  Use Core to connect capture to CopilotKit Intelligence. This experimental integration targets the join and batch APIs in [Intelligence #1569](https://github.com/CopilotKit/Intelligence/pull/1569).
@@ -39,9 +52,25 @@ if (result.status === "started") {
39
52
  copilotkit.stopTrajectory();
40
53
  ```
41
54
 
42
- An omitted `trajectoryId` generates a UUID. Start succeeds after Runtime authentication and the Phoenix channel join. The Runtime resolves the user on the server and sends only their ID to Intelligence. Browser-supplied identity and container IDs are ignored; container assignment is deferred. Setting `learningContainerIds` for authenticated capture produces a warning. That option applies only to custom sinks.
55
+ An omitted `trajectoryId` generates a UUID. Start succeeds after Runtime authentication and the Phoenix channel join. The Runtime resolves the user on the server. Browser-supplied identity and container IDs are ignored. Setting `learningContainerIds` in browser capture configuration produces a warning; that option applies only to custom sinks.
56
+
57
+ To assign authenticated capture to an existing Learning Space, configure the Runtime on the server:
58
+
59
+ ```ts
60
+ const intelligence = new CopilotKitIntelligence({
61
+ apiKey: process.env.CPK_INTELLIGENCE_API_KEY!,
62
+ getTrajectoryLearningContainerIds: ({ trajectoryId, user }) => {
63
+ // Use the verified application user to choose existing Spaces.
64
+ return ["expense-review"];
65
+ },
66
+ });
67
+ ```
68
+
69
+ The selector accepts synchronous or asynchronous results. Return `[]` to request no new assignments explicitly. Return `null` or `undefined` to omit assignments from the connect request. Each connect adds memberships; none of these results removes existing memberships, and a membership removed in Intelligence stays removed. Keep the result stable across reconnects. The selector needs no agent run or Thread, and a linked Thread's Space does not add the Trajectory. The browser chooses the Trajectory ID; the user comes from the Runtime. Intelligence validates that the selected Spaces belong to the API key's project.
43
70
 
44
- Capture includes page context, navigation, clicks, form edits, network requests, and developer events. Paths remain unchanged. Thread linking and agent events are deferred for this connection path.
71
+ Assignment problems never stop capture. If a selected Space does not exist, the Runtime logs a warning with the Trajectory and Space IDs and connects without any assignments, including the Spaces that exist. The selector has a five-second deadline and receives an optional second `AbortSignal` argument for cancelling its work when the request ends or the deadline expires. A callback error, invalid result, or deadline expiry logs a server error, and the Trajectory connects without assignments. Processing assigned events requires an Intelligence backend with Trajectory inputs enabled for Automatic Learning.
72
+
73
+ Capture includes page context, navigation, clicks, form edits, network requests, and developer events. Paths remain unchanged. When Runtime starts an agent run, Core sends `thread.linked` with the run's `threadId`, once for each Thread. Runtime creates the Thread before the run starts, so Intelligence can store the link. Click Thread context and agent events are deferred for this connection path.
45
74
 
46
75
  Core sends Phoenix `events` messages with `{ events, dropped }`. It flushes after two seconds, at 50 events, or before the batch exceeds 64 KiB. Each event must fit within 16 KiB, including sequence metadata. Sizes use serialized UTF-8 JSON. The Gateway replies with `{ highestSeq, accepted, rejected }`. This receipt confirms Redis acceptance, not a Postgres commit. Postgres projection follows asynchronously. The receipt does not identify individual rejected events.
47
76
 
@@ -53,7 +82,38 @@ Capture pauses while disconnected. Reconnect requests fresh credentials. Develop
53
82
 
54
83
  Stop cancels pending starts and reconnects. It attempts one final queued batch if no receipt is pending, then disconnects without waiting. Stop is not a guarantee that the last batch was saved. A batch still awaiting its receipt reports `PERSISTENCE_UNKNOWN`, including during a normal stop.
55
84
 
56
- In React, pass the same configuration to `CopilotKitProvider`. Set `learning.trajectoryId` to start automatically, or call the Core methods to control capture.
85
+ If a start or an established capture ends with an error and no `onError` is set, Core logs a console warning with the error code and a setup hint, such as a missing `runtimeUrl`, a Runtime without `intelligence`, or a missing `identifyUser`. Each outcome warns once per Core instance. Server error messages are never shown in the browser. Setting `onError` replaces the warning. Stops and recoverable connection loss do not warn.
86
+
87
+ ### React
88
+
89
+ Capture needs a Runtime configured with `intelligence` and `identifyUser`, and a project with Trajectories enabled. Then turn capture on in the provider:
90
+
91
+ ```tsx
92
+ <CopilotKitProvider runtimeUrl="/api/copilotkit" learning>
93
+ {children}
94
+ </CopilotKitProvider>
95
+ ```
96
+
97
+ `learning` turns capture on. For options, pass an object, such as `learning={{ onError }}`. `learning={false}` or removing the prop turns capture off, which suits a consent switch: `learning={hasConsent}`.
98
+
99
+ Capture starts after mount. Without `learning.trajectoryId`, the provider generates the Trajectory ID. The ID stays the same across rerenders, React StrictMode effect replays, reconnects, and option changes. Turning capture off and on again starts a new Trajectory with a new ID. Unmounting the provider stops capture. Read the active ID from `copilotkit.trajectoryId`, or subscribe with `onTrajectoryChanged`.
100
+
101
+ Set `learning.trajectoryId` to use your own ID instead.
102
+
103
+ To decide yourself when capture starts, set `autoStart: false`. The provider configures capture and waits for your app to call `copilotkit.startTrajectory()`:
104
+
105
+ ```tsx
106
+ <CopilotKitProvider
107
+ runtimeUrl="/api/copilotkit"
108
+ learning={{ autoStart: false }}
109
+ >
110
+ {children}
111
+ </CopilotKitProvider>
112
+ ```
113
+
114
+ With `autoStart: false`, the provider ignores `learning.trajectoryId` and logs a console warning. Pass the ID to `startTrajectory({ trajectoryId })` instead.
115
+
116
+ Earlier versions waited for `startTrajectory()` when the object had no `trajectoryId`, for example `learning={{ onError }}`. Those objects now start capture after mount. Add `autoStart: false` to keep the manual start.
57
117
 
58
118
  ## Custom batch sinks
59
119
 
package/dist/index.cjs CHANGED
@@ -117,13 +117,17 @@ function createRedactor() {
117
117
  const toggled = (records) => {
118
118
  for (const record of records) if (record.oldValue?.toLowerCase() === "password") seen.add(record.target);
119
119
  };
120
- /** Replaces remembered values, raw and in their JSON and URL encodings. */
121
- const scrub = (text) => {
120
+ /**
121
+ * Replaces remembered values, raw and in their JSON and URL encodings, plus
122
+ * `normalize(value)` when given and at least 4 characters long.
123
+ */
124
+ const scrub = (text, normalize) => {
122
125
  for (const value of [...remembered.values()].sort((a, b) => b.length - a.length)) for (const form of [
123
126
  value,
124
127
  JSON.stringify(value).slice(1, -1),
125
128
  encodeURIComponent(value),
126
- new URLSearchParams({ v: value }).toString().slice(2)
129
+ new URLSearchParams({ v: value }).toString().slice(2),
130
+ ...[normalize?.(value) ?? ""].filter((normal) => normal.length >= 4)
127
131
  ]) text = /^\d+$/.test(form) ? text.replace(RegExp(`(?<!\\d)${form}(?!\\d)`, "g"), REDACTED) : text.split(form).join(REDACTED);
128
132
  return text;
129
133
  };
@@ -141,6 +145,7 @@ function createRedactor() {
141
145
  };
142
146
  return {
143
147
  isPassword,
148
+ scrub,
144
149
  watch() {
145
150
  try {
146
151
  for (const input of document.querySelectorAll("input[type=password]")) seen.add(input);
@@ -224,6 +229,172 @@ function describeTarget(el, redact) {
224
229
  }
225
230
  };
226
231
  }
232
+ const MAX_LABEL_LENGTH = 80;
233
+ const MAX_WALK_NODES = 1e3;
234
+ const MAX_WALK_CHARS = 4 * MAX_LABEL_LENGTH;
235
+ const CONTROL_ROLE = /^(button|link|tab|menuitem(checkbox|radio)?|option|checkbox|switch|radio|treeitem)$/;
236
+ const CONTENTEDITABLE = "[contenteditable]:not([contenteditable=\"false\"])";
237
+ const TEXT_ENTRY = `${CONTENTEDITABLE}, [role~=textbox], [role~=searchbox], [role~=combobox]`;
238
+ const EDITABLE = `input, textarea, select, ${TEXT_ENTRY}`;
239
+ const HEADING = "h1, h2, h3, h4, h5, h6, [role~=heading]";
240
+ const STRUCTURE = `${HEADING}, p, ul, ol, li, dl, table, article, section, blockquote`;
241
+ const SKIPPED = "script, style, template, [hidden], [aria-hidden=\"true\"]";
242
+ const PHRASING = /^(b|i|u|em|strong|mark|small|sub|sup|abbr|code|kbd|s|q|cite|dfn|var|time|data|bdi|bdo|wbr|span)$/;
243
+ const CAPTION_INPUT = /^(submit|button|reset|image)$/;
244
+ const NON_TEXT_INPUT = /^(submit|button|reset|image|checkbox|radio|range|color|file|hidden)$/;
245
+ const IGNORABLE = /\p{Default_Ignorable_Code_Point}/gu;
246
+ const URL_LIKE = /(?:\b[a-z][\w+.-]{0,31}:\/\/|\bwww\.)[^\s<>"']*[^\s<>"'.,;:!?)]/gi;
247
+ const EMAIL = /[\p{L}\p{N}_.%+-]{1,64}@[\p{L}\p{N}-]{1,63}(?:\.[\p{L}\p{N}-]{1,63})+/gu;
248
+ const JWT = /(^|[^\w-])(?:eyJ[\w-]*\.[\w-]*\.[\w-]*|[\w-]{16,}\.[\w-]{16,}\.[\w-]{16,}|(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,})/g;
249
+ const TOKEN_RUN = /[\w+/=-]{24,}/g;
250
+ const roleOf = (element) => (element.getAttribute("role") ?? "").trim().split(/\s+/)[0].toLowerCase();
251
+ const idRefs = (element, name) => (element.getAttribute(name) ?? "").split(/\s+/).filter(Boolean);
252
+ const rootOf = (element) => {
253
+ const root = element.getRootNode();
254
+ return root instanceof Document || root instanceof ShadowRoot ? root : null;
255
+ };
256
+ /** Buttons, links with `href`, `summary`, and elements with a control role. */
257
+ const isControl = (element) => {
258
+ const role = roleOf(element);
259
+ return role ? CONTROL_ROLE.test(role) : element.matches("button, a[href], summary");
260
+ };
261
+ /** Text-like inputs, text areas, selects, and anything inside editable content or a text-entry role. */
262
+ const holdsValue = (element) => element instanceof HTMLInputElement ? !NON_TEXT_INPUT.test(element.type) : element.matches("textarea, select") || element.closest(TEXT_ENTRY) !== null;
263
+ /**
264
+ * The name an image contributes: `alt` of an `img`, or `aria-label` (else a
265
+ * plain `<title>`) of an `svg` or `role="img"` element. `undefined` when the
266
+ * element is not an image.
267
+ */
268
+ function imageName(element) {
269
+ if (element.localName === "img") return element.getAttribute("alt");
270
+ if (element.localName !== "svg" && roleOf(element) !== "img") return void 0;
271
+ const title = Array.from(element.children).find((child) => child.localName === "title" && child.childElementCount === 0);
272
+ return element.getAttribute("aria-label") ?? title?.textContent ?? null;
273
+ }
274
+ /**
275
+ * Rendered text. Inline formatting joins words; other elements separate them.
276
+ * Skips hidden subtrees, uses image names, and does
277
+ * not walk into images. Yields nothing for editable content or editable
278
+ * descendants. In `strict` mode (a control's own text) it also yields nothing
279
+ * past the walk bounds, for card-like structure, and for text that looks like
280
+ * separate fields: two `div`s that directly hold text, or three sibling
281
+ * elements that each carry text.
282
+ */
283
+ function safeText(root, strict) {
284
+ if (holdsValue(root)) return "";
285
+ let text = "";
286
+ let nodes = 0;
287
+ let paragraphs = 0;
288
+ let div;
289
+ const bearers = /* @__PURE__ */ new Map();
290
+ const add = (part) => text = (text + part).replace(/\s+/g, " ");
291
+ const bear = (start) => {
292
+ if (!strict) return true;
293
+ for (let child = start, parent = start.parentNode; child !== root && parent !== null; child = parent, parent = parent.parentNode) {
294
+ const siblings = bearers.get(parent) ?? /* @__PURE__ */ new Set();
295
+ bearers.set(parent, siblings);
296
+ if (siblings.has(child)) break;
297
+ if (siblings.add(child).size > 2) return false;
298
+ }
299
+ return true;
300
+ };
301
+ let node = root.firstChild;
302
+ while (node !== null) {
303
+ if (++nodes > MAX_WALK_NODES || text.length > MAX_WALK_CHARS) {
304
+ if (strict) return "";
305
+ break;
306
+ }
307
+ let descend = false;
308
+ if (node instanceof Text) {
309
+ const parent = node.parentNode;
310
+ let holder = parent;
311
+ while (holder !== root && PHRASING.test(holder.localName)) holder = holder.parentElement;
312
+ if (/\S/.test(node.data) && (!bear(parent) || strict && holder.localName === "div" && (div ??= holder) !== holder)) return "";
313
+ add(node.data);
314
+ } else if (node instanceof Element) {
315
+ if (node.matches(EDITABLE)) return "";
316
+ if (strict && node.matches(STRUCTURE) && !(root.localName === "summary" && node.matches(HEADING)) && (node.localName !== "p" || ++paragraphs > 1)) return "";
317
+ const name = node.matches(SKIPPED) ? null : imageName(node);
318
+ if (name) {
319
+ if (!bear(node)) return "";
320
+ add(` ${name} `);
321
+ } else if (name === void 0) {
322
+ if (!PHRASING.test(node.localName)) add(" ");
323
+ descend = true;
324
+ }
325
+ }
326
+ if (descend && node.firstChild !== null) node = node.firstChild;
327
+ else {
328
+ while (node !== root && node.nextSibling === null) {
329
+ node = node.parentNode;
330
+ if (!PHRASING.test(node.localName)) add(" ");
331
+ }
332
+ node = node === root ? null : node.nextSibling;
333
+ }
334
+ }
335
+ return text;
336
+ }
337
+ /** An option or tree item of an autocomplete or search popup, whose text usually repeats what the user typed. */
338
+ const isSuggestion = (element) => (element.localName === "option" || /^(option|treeitem)$/.test(roleOf(element))) && inSuggestionList(element);
339
+ /** Whether `element` is, or is inside, a combobox, a datalist, or the popup a combobox or autocomplete field controls or owns. */
340
+ function inSuggestionList(element) {
341
+ if (element.closest("[role~=combobox], datalist") !== null) return true;
342
+ const root = rootOf(element);
343
+ return root !== null && Array.from(root.querySelectorAll("[role~=combobox], [aria-autocomplete]")).some((owner) => [...idRefs(owner, "aria-controls"), ...idRefs(owner, "aria-owns")].some((id) => root.getElementById(id)?.contains(element)));
344
+ }
345
+ function labelledBy(element) {
346
+ const root = rootOf(element);
347
+ return root === null ? null : idRefs(element, "aria-labelledby").map((id) => {
348
+ const referenced = root.getElementById(id);
349
+ return referenced === null || inSuggestionList(referenced) ? "" : safeText(referenced, false);
350
+ }).join(" ");
351
+ }
352
+ /** A control's own text, unless it is longer than a label: then it is content, not a name. */
353
+ const normalize = (text) => text.replace(IGNORABLE, "").replace(/\s+/g, " ").trim();
354
+ function contentText(element) {
355
+ const text = safeText(element, true);
356
+ return Array.from(normalize(text)).length > MAX_LABEL_LENGTH ? "" : text;
357
+ }
358
+ /** Removes invisible characters, credentials, emails, URLs, and tokens, then caps the length. */
359
+ function cleanLabel(raw, redact) {
360
+ const chars = Array.from(redact.scrub(normalize(redact.body(raw.replace(IGNORABLE, "")) ?? ""), normalize).replace(URL_LIKE, "[url]").replace(EMAIL, "[email]").replace(JWT, `$1${REDACTED}`).replace(TOKEN_RUN, (run) => /\d/.test(run) ? REDACTED : run));
361
+ return chars.length > MAX_LABEL_LENGTH ? `${chars.slice(0, MAX_LABEL_LENGTH - 1).join("").trimEnd()}…` : chars.join("");
362
+ }
363
+ function firstLabel(sources, redact) {
364
+ for (const source of sources) {
365
+ const label = cleanLabel(source() ?? "", redact);
366
+ if (label) return label;
367
+ }
368
+ }
369
+ /**
370
+ * A short name for the clicked control, from the first source that yields one:
371
+ * `aria-labelledby`, `aria-label`, an input caption or `alt`, `title` (controls
372
+ * only), then the text of a control named by its content. Containers, cards,
373
+ * and editable content never contribute their text. An autocomplete suggestion,
374
+ * or anything inside one, gets no label from any source; `data-copilotkit-action`
375
+ * names it instead. An icon without its own name takes the name of the control
376
+ * around it.
377
+ */
378
+ function clickLabel(element, redact) {
379
+ const option = element.closest("option, [role~=option], [role~=treeitem]");
380
+ if (option !== null && isSuggestion(option)) return void 0;
381
+ const named = [() => labelledBy(element), () => element.getAttribute("aria-label")];
382
+ if (/^(img|svg)$/.test(element.localName) || /^(img|presentation|none)$/.test(roleOf(element))) {
383
+ const own = firstLabel([...named, () => imageName(element) ?? null], redact);
384
+ if (own !== void 0) return own;
385
+ let control = element.parentElement;
386
+ while (control !== null && !isControl(control)) control = control.parentElement;
387
+ return control === null ? void 0 : clickLabel(control, redact);
388
+ }
389
+ const control = isControl(element) && !holdsValue(element);
390
+ const captionInput = element instanceof HTMLInputElement && CAPTION_INPUT.test(element.type);
391
+ return firstLabel([
392
+ ...named,
393
+ () => captionInput ? element.type === "image" ? element.getAttribute("alt") : element.value : null,
394
+ () => control || captionInput ? element.getAttribute("title") : null,
395
+ () => control ? contentText(element) : null
396
+ ], redact);
397
+ }
227
398
  /** Snapshot live control properties; HTML attributes do not reflect edited values. */
228
399
  function readControlValue(element, redact) {
229
400
  if (element instanceof HTMLInputElement) return {
@@ -241,7 +412,7 @@ function readControlValue(element, redact) {
241
412
  selectedValues: Array.from(element.selectedOptions, (option) => option.value)
242
413
  };
243
414
  if (element instanceof HTMLTextAreaElement) return { value: element.value };
244
- if (element.matches("[contenteditable]:not([contenteditable=\"false\"])")) return { value: element.textContent ?? "" };
415
+ if (element.matches(CONTENTEDITABLE)) return { value: element.textContent ?? "" };
245
416
  return {};
246
417
  }
247
418
  function eventElement(event) {
@@ -262,12 +433,21 @@ function isNear(origin, event) {
262
433
  function installClickCapture(params) {
263
434
  const { emit, getRoute, enrich, isTrusted = (event) => event.isTrusted, redact = createRedactor() } = params;
264
435
  let origin = null;
436
+ const describeClick = (el) => {
437
+ const described = describeTarget(el, redact);
438
+ if (described === null) return null;
439
+ try {
440
+ const label = clickLabel(described.element, redact);
441
+ if (label !== void 0) described.target.label = label;
442
+ } catch {}
443
+ return described;
444
+ };
265
445
  const onPointerDown = (event) => {
266
446
  try {
267
447
  if (event.isPrimary === false) return;
268
448
  const el = eventElement(event);
269
449
  origin = {
270
- described: el === null ? null : describeTarget(el, redact),
450
+ described: el === null ? null : describeClick(el),
271
451
  x: event.clientX,
272
452
  y: event.clientY
273
453
  };
@@ -282,7 +462,7 @@ function installClickCapture(params) {
282
462
  if (!isTrusted(event)) return;
283
463
  const el = eventElement(event);
284
464
  if (el === null) return;
285
- const described = pointerOrigin !== null && isRootOrDetached(el) && isNear(pointerOrigin, event) ? pointerOrigin.described : describeTarget(el, redact);
465
+ const described = pointerOrigin !== null && isRootOrDetached(el) && isNear(pointerOrigin, event) ? pointerOrigin.described : describeClick(el);
286
466
  if (described === null) return;
287
467
  const input = event.detail === 0 ? "keyboard" : "pointer";
288
468
  emit("click", {
@@ -312,41 +492,138 @@ function installClickCapture(params) {
312
492
 
313
493
  //#endregion
314
494
  //#region src/inputs.ts
315
- /** Records user edits without changing the control or interfering with its listeners. */
495
+ const TEXT_DEBOUNCE_MS = 300;
496
+ const TEXT_INPUT_TYPES = new Set([
497
+ "text",
498
+ "search",
499
+ "tel",
500
+ "url",
501
+ "email",
502
+ "password",
503
+ "number"
504
+ ]);
505
+ /** Records settled user edits without interfering with the host app's controls. */
316
506
  function installInputCapture(params) {
317
507
  const { emit, isTrusted = (event) => event.isTrusted, redact = createRedactor() } = params;
318
- const onInput = (event) => {
508
+ let active = true;
509
+ const pending = /* @__PURE__ */ new Map();
510
+ const changeEcho = /* @__PURE__ */ new WeakMap();
511
+ const composing = /* @__PURE__ */ new WeakMap();
512
+ const cancel = (element) => {
513
+ clearTimeout(pending.get(element)?.timer);
514
+ pending.delete(element);
515
+ };
516
+ const deliver = (element, edit) => {
319
517
  try {
320
- if (!isTrusted(event)) return;
321
- const [element] = event.composedPath();
322
- if (!(element instanceof Element)) return;
323
- const described = describeTarget(element, redact);
324
- if (described === null) return;
325
- const value = readControlValue(element, redact);
326
- if (!("value" in value)) return;
327
- emit("input", {
328
- eventType: event.type,
518
+ if (!active || describeTarget(element, redact) === null) return;
519
+ if (redact.isPassword(element) && edit.state.value !== REDACTED) return;
520
+ if (edit.payload.eventType === "input") changeEcho.set(element, JSON.stringify(edit.state));
521
+ edit.payload.url = redact.url(edit.payload.url);
522
+ edit.payload.target.text = redact.url(edit.payload.target.text);
523
+ edit.payload.target.attributes = Object.fromEntries(Object.entries(edit.payload.target.attributes).map(([key, value]) => [key, redact.url(value)]));
524
+ emit("input", edit.payload);
525
+ } catch {}
526
+ };
527
+ const flush = () => {
528
+ const edits = [...pending];
529
+ for (const [element] of edits) cancel(element);
530
+ for (const [element, edit] of edits) deliver(element, edit);
531
+ };
532
+ const observe = (element, eventType, settle) => {
533
+ if (pending.size > 0 && !pending.has(element)) flush();
534
+ if (!active) return;
535
+ const described = describeTarget(element, redact);
536
+ if (described === null) {
537
+ cancel(element);
538
+ return;
539
+ }
540
+ const state = readControlValue(element, redact);
541
+ if (!("value" in state)) return;
542
+ const text = element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement && TEXT_INPUT_TYPES.has(element.type) || element.matches("[contenteditable]:not([contenteditable=\"false\"])");
543
+ if (!text) flush();
544
+ const signature = JSON.stringify(state);
545
+ const echo = eventType === "change" && (signature === JSON.stringify(pending.get(element)?.state) || signature === changeEcho.get(element));
546
+ changeEcho.delete(element);
547
+ if (echo) {
548
+ flush();
549
+ changeEcho.delete(element);
550
+ return;
551
+ }
552
+ cancel(element);
553
+ const edit = {
554
+ state,
555
+ payload: {
556
+ eventType,
329
557
  target: {
330
558
  ...described.target,
331
- ...value
559
+ ...state
332
560
  },
333
561
  url: redact.url(location.href),
334
562
  route: location.pathname
335
- });
563
+ }
564
+ };
565
+ if (!text) {
566
+ deliver(element, edit);
567
+ return;
568
+ }
569
+ if (settle) edit.timer = setTimeout(() => {
570
+ cancel(element);
571
+ deliver(element, edit);
572
+ }, TEXT_DEBOUNCE_MS);
573
+ pending.set(element, edit);
574
+ };
575
+ const onEdit = (event) => {
576
+ try {
577
+ if (!active || !isTrusted(event)) return;
578
+ const [element] = event.composedPath();
579
+ if (!(element instanceof Element)) return;
580
+ if (event.type === "compositionstart") {
581
+ composing.set(element, true);
582
+ clearTimeout(pending.get(element)?.timer);
583
+ return;
584
+ }
585
+ if (event.type === "compositionend") composing.set(element, false);
586
+ const inComposition = composing.get(element) === true || composing.get(element) !== false && "isComposing" in event && event.isComposing === true;
587
+ observe(element, event.type === "compositionend" ? "input" : event.type, !inComposition);
588
+ if (event.type === "change") flush();
336
589
  } catch {}
337
590
  };
338
- window.addEventListener("input", onInput, {
591
+ const onAction = (event) => {
592
+ if (event.type !== "pagehide" && !isTrusted(event)) return;
593
+ if (event.type === "keydown") {
594
+ const [element] = event.composedPath();
595
+ if (!(event instanceof KeyboardEvent) || event.key !== "Enter" || event.isComposing || event.keyCode === 229 || element instanceof Element && composing.get(element) === true) return;
596
+ }
597
+ flush();
598
+ };
599
+ const editTypes = [
600
+ "input",
601
+ "change",
602
+ "compositionstart",
603
+ "compositionend"
604
+ ];
605
+ const actionTypes = [
606
+ "click",
607
+ "submit",
608
+ "focusout",
609
+ "pagehide",
610
+ "keydown"
611
+ ];
612
+ for (const type of editTypes) window.addEventListener(type, onEdit, {
339
613
  capture: true,
340
614
  passive: true
341
615
  });
342
- window.addEventListener("change", onInput, {
616
+ for (const type of actionTypes) window.addEventListener(type, onAction, {
343
617
  capture: true,
344
618
  passive: true
345
619
  });
346
- return () => {
347
- window.removeEventListener("input", onInput, { capture: true });
348
- window.removeEventListener("change", onInput, { capture: true });
620
+ const uninstall = () => {
621
+ active = false;
622
+ for (const element of pending.keys()) cancel(element);
623
+ for (const type of editTypes) window.removeEventListener(type, onEdit, true);
624
+ for (const type of actionTypes) window.removeEventListener(type, onAction, true);
349
625
  };
626
+ return Object.assign(uninstall, { flush });
350
627
  }
351
628
 
352
629
  //#endregion
@@ -866,6 +1143,8 @@ function createCollector(options) {
866
1143
  const record = (name, value) => {
867
1144
  const current = session;
868
1145
  if (current === null) return;
1146
+ if (name !== "input" && name !== "network") current.flushInputs?.();
1147
+ if (session !== current) return;
869
1148
  try {
870
1149
  seq += 1;
871
1150
  const event = {
@@ -878,7 +1157,7 @@ function createCollector(options) {
878
1157
  timestamp: Date.now()
879
1158
  };
880
1159
  const kept = beforeSend === void 0 ? event : beforeSend(event);
881
- if (kept === null) return;
1160
+ if (kept === null || session !== current) return;
882
1161
  current.queue.push(kept);
883
1162
  if (current.queue.length >= FLUSH_SIZE) flush();
884
1163
  } catch {}
@@ -921,10 +1200,14 @@ function createCollector(options) {
921
1200
  ignoreUrls,
922
1201
  redact
923
1202
  }));
924
- if (capture.inputs) current.uninstalls.push(installInputCapture({
925
- emit: recordCurrent,
926
- redact
927
- }));
1203
+ if (capture.inputs) {
1204
+ const inputs = installInputCapture({
1205
+ emit: recordCurrent,
1206
+ redact
1207
+ });
1208
+ current.flushInputs = inputs.flush;
1209
+ current.uninstalls.push(inputs);
1210
+ }
928
1211
  const onPageHide = () => flush({ beacon: true });
929
1212
  window.addEventListener("pagehide", onPageHide);
930
1213
  current.uninstalls.push(() => window.removeEventListener("pagehide", onPageHide));
@@ -939,6 +1222,8 @@ function createCollector(options) {
939
1222
  const stop = () => {
940
1223
  const current = session;
941
1224
  if (current === null) return;
1225
+ current.flushInputs?.();
1226
+ if (session !== current) return;
942
1227
  clearInterval(current.timer);
943
1228
  for (const uninstall of current.uninstalls) uninstall();
944
1229
  flush();
@@ -1018,7 +1303,9 @@ function isJson(value, ancestors = /* @__PURE__ */ new Set()) {
1018
1303
  */
1019
1304
  function createTrajectoryCollector(options) {
1020
1305
  let active = false;
1306
+ let generation = 0;
1021
1307
  let uninstalls = [];
1308
+ let flushInputs;
1022
1309
  const report = (code, message) => {
1023
1310
  try {
1024
1311
  options.onError?.({
@@ -1030,6 +1317,9 @@ function createTrajectoryCollector(options) {
1030
1317
  const getRoute = () => toRoute(location.pathname);
1031
1318
  const record = (name, value) => {
1032
1319
  if (!active) return;
1320
+ const current = generation;
1321
+ if (name !== "input" && name !== "network") flushInputs?.();
1322
+ if (!active || generation !== current) return;
1033
1323
  let payload;
1034
1324
  try {
1035
1325
  const event = {
@@ -1039,7 +1329,7 @@ function createTrajectoryCollector(options) {
1039
1329
  value
1040
1330
  };
1041
1331
  const kept = options.beforeSend === void 0 ? event : options.beforeSend(event);
1042
- if (kept === null || !active) return;
1332
+ if (kept === null || !active || generation !== current) return;
1043
1333
  if (kept.type !== "CUSTOM" || typeof kept.name !== "string" || !kept.name.trim() || kept.name.length > 200 || kept.name.includes("\0") || !Number.isFinite(kept.timestamp) || kept.timestamp <= 0 || kept.timestamp >= 0xe677d21fdc00 || !isJson(kept.value)) {
1044
1334
  report("INVALID_EVENT", "The event must contain a name, timestamp, and JSON value.");
1045
1335
  return;
@@ -1069,6 +1359,12 @@ function createTrajectoryCollector(options) {
1069
1359
  record(name, value);
1070
1360
  };
1071
1361
  const stop = () => {
1362
+ const current = generation;
1363
+ const flush = flushInputs;
1364
+ flushInputs = void 0;
1365
+ flush?.();
1366
+ if (generation !== current) return;
1367
+ generation++;
1072
1368
  active = false;
1073
1369
  const installed = uninstalls;
1074
1370
  uninstalls = [];
@@ -1078,6 +1374,7 @@ function createTrajectoryCollector(options) {
1078
1374
  start() {
1079
1375
  if (active || typeof window === "undefined") return;
1080
1376
  active = true;
1377
+ generation++;
1081
1378
  if (typeof window.addEventListener !== "function") return;
1082
1379
  const redact = createRedactor();
1083
1380
  try {
@@ -1091,10 +1388,14 @@ function createTrajectoryCollector(options) {
1091
1388
  emit: capture,
1092
1389
  redact
1093
1390
  }));
1094
- if (options.capture?.inputs !== false) uninstalls.push(installInputCapture({
1095
- emit: capture,
1096
- redact
1097
- }));
1391
+ if (options.capture?.inputs !== false) {
1392
+ const inputs = installInputCapture({
1393
+ emit: capture,
1394
+ redact
1395
+ });
1396
+ flushInputs = inputs.flush;
1397
+ uninstalls.push(inputs);
1398
+ }
1098
1399
  if (options.capture?.network !== false) uninstalls.push(installNetworkCapture({
1099
1400
  emit: capture,
1100
1401
  ignoreUrls: options.ignoreUrls ?? [],