@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 +63 -3
- package/dist/index.cjs +335 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +6 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +335 -34
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/scripts/check-size.mjs +2 -2
- package/src/__tests__/clicks.test.ts +499 -1
- package/src/__tests__/collector.test.ts +152 -0
- package/src/__tests__/inputs.test.ts +400 -1
- package/src/__tests__/redact.test.ts +4 -2
- package/src/__tests__/trajectory-collector.test.ts +105 -0
- package/src/clicks.ts +308 -3
- package/src/collector.ts +11 -5
- package/src/inputs.ts +167 -19
- package/src/redact.ts +7 -2
- package/src/trajectory-collector.ts +16 -2
- package/src/types.ts +6 -0
- package/CHANGELOG.md +0 -9
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
121
|
-
|
|
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(
|
|
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 :
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
321
|
-
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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(
|
|
616
|
+
for (const type of actionTypes) window.addEventListener(type, onAction, {
|
|
343
617
|
capture: true,
|
|
344
618
|
passive: true
|
|
345
619
|
});
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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)
|
|
925
|
-
|
|
926
|
-
|
|
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)
|
|
1095
|
-
|
|
1096
|
-
|
|
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 ?? [],
|