@hachej/boring-feedback 0.0.0-stage → 0.2.4

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.
Files changed (92) hide show
  1. package/INVARIANTS.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +20 -2
  4. package/dist/agent/capability.d.ts +52 -0
  5. package/dist/agent/capability.js +230 -0
  6. package/dist/agent/index.d.ts +4 -0
  7. package/dist/agent/index.js +5 -0
  8. package/dist/agent/preview.d.ts +10 -0
  9. package/dist/agent/preview.js +27 -0
  10. package/dist/format/index.d.ts +7 -0
  11. package/dist/format/index.js +6 -0
  12. package/dist/format/json.d.ts +30 -0
  13. package/dist/format/json.js +193 -0
  14. package/dist/format/preview.d.ts +28 -0
  15. package/dist/format/preview.js +45 -0
  16. package/dist/format/readable.d.ts +10 -0
  17. package/dist/format/readable.js +19 -0
  18. package/dist/format/report.d.ts +187 -0
  19. package/dist/format/report.js +669 -0
  20. package/dist/format/requests.d.ts +53 -0
  21. package/dist/format/requests.js +69 -0
  22. package/dist/page/app-element.d.ts +79 -0
  23. package/dist/page/app-element.js +451 -0
  24. package/dist/page/canaries.d.ts +46 -0
  25. package/dist/page/canaries.js +142 -0
  26. package/dist/page/index.d.ts +6 -0
  27. package/dist/page/index.js +8 -0
  28. package/dist/page/picker/index.d.ts +4 -0
  29. package/dist/page/picker/index.js +4 -0
  30. package/dist/page/picker/label.d.ts +16 -0
  31. package/dist/page/picker/label.js +58 -0
  32. package/dist/page/picker/overlay.d.ts +42 -0
  33. package/dist/page/picker/overlay.js +126 -0
  34. package/dist/page/picker/picker.d.ts +72 -0
  35. package/dist/page/picker/picker.js +386 -0
  36. package/dist/page/privacy/digest.d.ts +6 -0
  37. package/dist/page/privacy/digest.js +24 -0
  38. package/dist/page/privacy/index.d.ts +6 -0
  39. package/dist/page/privacy/index.js +5 -0
  40. package/dist/page/privacy/name.d.ts +8 -0
  41. package/dist/page/privacy/name.js +106 -0
  42. package/dist/page/privacy/policy.d.ts +65 -0
  43. package/dist/page/privacy/policy.js +125 -0
  44. package/dist/page/privacy/route.d.ts +6 -0
  45. package/dist/page/privacy/route.js +20 -0
  46. package/dist/page/privacy/serialize.d.ts +42 -0
  47. package/dist/page/privacy/serialize.js +90 -0
  48. package/dist/page/voice/align.d.ts +36 -0
  49. package/dist/page/voice/align.js +122 -0
  50. package/dist/page/voice/capture.d.ts +43 -0
  51. package/dist/page/voice/capture.js +186 -0
  52. package/dist/page/voice/index.d.ts +3 -0
  53. package/dist/page/voice/index.js +3 -0
  54. package/dist/preview/agent.d.ts +34 -0
  55. package/dist/preview/agent.js +91 -0
  56. package/dist/preview/index.d.ts +5 -0
  57. package/dist/preview/index.js +6 -0
  58. package/dist/preview/page.d.ts +50 -0
  59. package/dist/preview/page.js +293 -0
  60. package/dist/preview/session.d.ts +63 -0
  61. package/dist/preview/session.js +110 -0
  62. package/dist/source/index.d.ts +24 -0
  63. package/dist/source/index.js +59 -0
  64. package/dist/source/jsx-dev-runtime.d.ts +5 -0
  65. package/dist/source/jsx-dev-runtime.js +29 -0
  66. package/dist/store/index.d.ts +5 -0
  67. package/dist/store/index.js +4 -0
  68. package/dist/store/mentions.d.ts +17 -0
  69. package/dist/store/mentions.js +27 -0
  70. package/dist/store/store.d.ts +121 -0
  71. package/dist/store/store.js +353 -0
  72. package/dist/tickets/index.d.ts +5 -0
  73. package/dist/tickets/index.js +5 -0
  74. package/dist/tickets/sinks.d.ts +27 -0
  75. package/dist/tickets/sinks.js +84 -0
  76. package/dist/tickets/ticket.d.ts +93 -0
  77. package/dist/tickets/ticket.js +148 -0
  78. package/dist/ui/annotate.d.ts +103 -0
  79. package/dist/ui/annotate.js +154 -0
  80. package/dist/ui/index.d.ts +7 -0
  81. package/dist/ui/index.js +8 -0
  82. package/dist/ui/report.d.ts +51 -0
  83. package/dist/ui/report.js +58 -0
  84. package/dist/ui/save.d.ts +45 -0
  85. package/dist/ui/save.js +65 -0
  86. package/dist/ui/session.d.ts +186 -0
  87. package/dist/ui/session.js +762 -0
  88. package/dist/ui/show.d.ts +43 -0
  89. package/dist/ui/show.js +72 -0
  90. package/dist/ui/state.d.ts +11 -0
  91. package/dist/ui/state.js +21 -0
  92. package/package.json +98 -4
@@ -0,0 +1,125 @@
1
+ // The privacy policy for page content (FEEDBACK.md, "Privacy policy for page content"; FEEDBACK-8).
2
+ // Allowlist-based: anything not allowed is dropped or masked. Every option that loosens the default is recorded in `widened`.
3
+ /** Marks a region whose text (and `aria-label`/`aria-description`) may leave the page. Presence counts, whatever its value. */
4
+ export const FEEDBACK_VISIBLE_ATTRIBUTE = 'data-feedback-visible';
5
+ /** Marks a subtree that is never serialized, named or picked (the application's own chrome, the agent bar). */
6
+ export const FEEDBACK_IGNORE_ATTRIBUTE = 'data-feedback-ignore';
7
+ /** Marks the picker overlay's host element; it and its subtree are excluded from every snapshot. */
8
+ export const FEEDBACK_OVERLAY_ATTRIBUTE = 'data-feedback-overlay';
9
+ /** The name returned whenever the accessible name would come from masked or disallowed sources. */
10
+ export const MASKED_NAME = 'masked';
11
+ /** Attributes kept by default. Values still pass `keptValue`. */
12
+ export const DEFAULT_KEPT_ATTRIBUTES = Object.freeze([
13
+ 'role', 'type', 'data-testid', 'data-feedback-id', 'data-source',
14
+ 'aria-expanded', 'aria-pressed', 'aria-checked', 'aria-selected', 'aria-disabled', 'aria-hidden', 'aria-current',
15
+ ]);
16
+ /** Kept only inside a visible region. */
17
+ export const VISIBLE_REGION_ATTRIBUTES = Object.freeze(['aria-label', 'aria-description']);
18
+ /** Channels that are always masked, so no option can widen them: values, placeholders, event handlers and inline documents. */
19
+ const NEVER_ALLOWED = /^(?:on.*|value|placeholder|srcdoc)$/;
20
+ const ATTRIBUTE_NAME = /^[a-z_:][a-z0-9_.:-]*$/;
21
+ /** Builds the frozen version 1 policy. Throws on attribute names that name an always-masked channel or are malformed. */
22
+ export function createPrivacyPolicy(options = {}) {
23
+ const allow = [...new Set((options.allowAttributes ?? []).map(name => name.toLowerCase()))].sort();
24
+ for (const name of allow) {
25
+ if (!ATTRIBUTE_NAME.test(name))
26
+ throw new TypeError(`Not an attribute name: ${JSON.stringify(name)}`);
27
+ if (NEVER_ALLOWED.test(name))
28
+ throw new TypeError(`The ${name} attribute is always masked and cannot be allowed`);
29
+ }
30
+ const extra = allow.filter(name => !DEFAULT_KEPT_ATTRIBUTES.includes(name));
31
+ const widened = extra.map(name => `allowAttributes:${name}`);
32
+ const policy = { version: 1, widened, allowAttributes: Object.freeze(extra) };
33
+ if (options.visibleSelector !== undefined) {
34
+ const selector = options.visibleSelector.trim();
35
+ if (!selector)
36
+ throw new TypeError('visibleSelector must not be empty');
37
+ policy.visibleSelector = selector;
38
+ widened.push(`visibleSelector:${selector}`);
39
+ }
40
+ if (options.routeOf !== undefined) {
41
+ if (typeof options.routeOf !== 'function')
42
+ throw new TypeError('routeOf must be a function');
43
+ policy.routeOf = options.routeOf;
44
+ widened.push('routeOf');
45
+ }
46
+ if (options.label !== undefined) {
47
+ policy.label = String(options.label);
48
+ widened.push('label');
49
+ }
50
+ Object.freeze(widened);
51
+ return Object.freeze(policy);
52
+ }
53
+ /** The policy as a report records it (`observed.policy` in `feedback@1`). */
54
+ export function policyRecord(policy) {
55
+ return Object.freeze({ version: policy.version, widened: Object.freeze([...policy.widened]) });
56
+ }
57
+ /** The document title as it may leave the page: only the application's label, never `document.title`. */
58
+ export function titleFor(policy) {
59
+ return policy.label;
60
+ }
61
+ /** `*` of equal length, per code point. */
62
+ export function maskText(text) {
63
+ return '*'.repeat(Array.from(text).length);
64
+ }
65
+ /** Whitespace runs collapse to one space; ends are trimmed. */
66
+ export function normalizeText(text) {
67
+ return text.replace(/\s+/g, ' ').trim();
68
+ }
69
+ /** A kept attribute value with three or more digits in a row, or an `@`, is dropped. */
70
+ export function keptValue(value) {
71
+ return !/\d{3}/.test(value) && !value.includes('@');
72
+ }
73
+ /** `data-source` is code, not page data: a project-relative `path:line` (WP8's development stamp)
74
+ * is kept whatever its line number; anything else (absolute, parent, URL, `@`, odd characters) is dropped. */
75
+ export const SOURCE_LOCATION = /^(?!\/)(?![A-Za-z]:)(?!.*(?:^|\/)\.\.(?:\/|:))[\w.$+()[\]-]+(?:\/[\w.$+()[\]-]+)*\.[cm]?[jt]sx?:[1-9]\d{0,6}$/;
76
+ export function keptAttributeValue(name, value) {
77
+ return name === 'data-source' ? SOURCE_LOCATION.test(value) : keptValue(value);
78
+ }
79
+ const closest = (element, selector) => {
80
+ try {
81
+ return element.closest(selector) !== null;
82
+ }
83
+ catch {
84
+ return false;
85
+ } // An invalid selector widens nothing.
86
+ };
87
+ /** True when the element or an ancestor marks a visible region. */
88
+ export function inVisibleRegion(element, policy) {
89
+ if (closest(element, `[${FEEDBACK_VISIBLE_ATTRIBUTE}]`))
90
+ return true;
91
+ return policy.visibleSelector !== undefined && closest(element, policy.visibleSelector);
92
+ }
93
+ /** True when the element is, or is inside, an ignored subtree or the picker overlay. */
94
+ export function isExcluded(element) {
95
+ return closest(element, `[${FEEDBACK_IGNORE_ATTRIBUTE}],[${FEEDBACK_OVERLAY_ATTRIBUTE}]`);
96
+ }
97
+ const VALUE_CONTAINERS = new Set(['textarea', 'select', 'option', 'optgroup', 'datalist', 'output', 'input']);
98
+ /** Form values, `textarea` and `contenteditable` content: masked even inside a visible region. */
99
+ export function isAlwaysMasked(element) {
100
+ for (let current = element; current; current = current.parentElement) {
101
+ if (VALUE_CONTAINERS.has(current.localName))
102
+ return true;
103
+ const editable = current.getAttribute('contenteditable');
104
+ if (editable !== null && editable.toLowerCase() !== 'false')
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+ /** The allowed attributes of one element, keys sorted. */
110
+ export function allowedAttributes(element, policy) {
111
+ const visible = inVisibleRegion(element, policy);
112
+ const names = [];
113
+ for (const attribute of Array.from(element.attributes)) {
114
+ const name = attribute.name.toLowerCase();
115
+ if (DEFAULT_KEPT_ATTRIBUTES.includes(name) || policy.allowAttributes.includes(name) || (visible && VISIBLE_REGION_ATTRIBUTES.includes(name)))
116
+ names.push(name);
117
+ }
118
+ const attrs = {};
119
+ for (const name of [...new Set(names)].sort()) {
120
+ const value = element.getAttribute(name);
121
+ if (value !== null && keptAttributeValue(name, value))
122
+ attrs[name] = value;
123
+ }
124
+ return attrs;
125
+ }
@@ -0,0 +1,6 @@
1
+ import { type PrivacyPolicy, type RouteLocation } from './policy.js';
2
+ /** Each non-empty path segment becomes `*` of equal length; separators are kept. */
3
+ export declare function maskedPath(pathname: string): string;
4
+ /** `routeOf(location)` when the policy has one and it returns a string, cut before any `?` or `#`; otherwise the masked path. */
5
+ export declare function routeFor(location: RouteLocation, policy: PrivacyPolicy): string;
6
+ //# sourceMappingURL=route.d.ts.map
@@ -0,0 +1,20 @@
1
+ // The route as it may leave the page: the application's template, or the path with every segment masked. Never query or fragment.
2
+ import { maskText } from './policy.js';
3
+ const pathOnly = (value) => value.split(/[?#]/, 1)[0] ?? '';
4
+ /** Each non-empty path segment becomes `*` of equal length; separators are kept. */
5
+ export function maskedPath(pathname) {
6
+ return pathOnly(pathname).split('/').map(segment => maskText(segment)).join('/');
7
+ }
8
+ /** `routeOf(location)` when the policy has one and it returns a string, cut before any `?` or `#`; otherwise the masked path. */
9
+ export function routeFor(location, policy) {
10
+ const pathname = String(location.pathname ?? '');
11
+ if (policy.routeOf) {
12
+ try {
13
+ const template = policy.routeOf({ pathname, search: '', hash: '' });
14
+ if (typeof template === 'string')
15
+ return pathOnly(template);
16
+ }
17
+ catch { /* fall back to the masked path */ }
18
+ }
19
+ return maskedPath(pathname);
20
+ }
@@ -0,0 +1,42 @@
1
+ import { type PrivacyPolicy } from './policy.js';
2
+ export interface AppDomNode {
3
+ /** Lower-case local name. */
4
+ readonly tag: string;
5
+ /** Allowed attributes only, keys sorted. */
6
+ readonly attrs: Readonly<Record<string, string>>;
7
+ /** The element's own text nodes, whitespace-normalized and joined by one space; masked unless visible. */
8
+ readonly text: string;
9
+ readonly children: readonly AppDomNode[];
10
+ /** 0-based position among the parent's element children with the same tag (`:nth-of-type` minus one), counted in the live DOM. */
11
+ readonly index: number;
12
+ }
13
+ export interface AppDomLimits {
14
+ /** Maximum number of nodes. */
15
+ readonly maxNodes: number;
16
+ /** Maximum depth below the root (the root is depth 0). */
17
+ readonly maxDepth: number;
18
+ /** Upper bound on the UTF-8 length of the root's canonical JSON. */
19
+ readonly maxBytes: number;
20
+ }
21
+ export interface AppDomSnapshot {
22
+ readonly format: 'app.dom@1';
23
+ /** `null` when the root itself is excluded (ignored subtree, overlay, or a non-content element). */
24
+ readonly root: AppDomNode | null;
25
+ /** True when any limit cut the tree: the snapshot is then a prefix (document order) and must never be treated as complete. */
26
+ readonly truncated: boolean;
27
+ readonly nodes: number;
28
+ readonly limits: AppDomLimits;
29
+ }
30
+ export declare const PAGE_LIMITS: AppDomLimits;
31
+ export declare const ELEMENT_LIMITS: AppDomLimits;
32
+ export interface SerializeElementOptions {
33
+ /** Maximum nodes in the element's snapshot, the element included. */
34
+ readonly subtreeLimit?: number;
35
+ readonly maxDepth?: number;
36
+ readonly maxBytes?: number;
37
+ }
38
+ /** The masked snapshot of one element and its subtree (default: at most 50 nodes, 16 levels, 2 KiB). */
39
+ export declare function serializeElement(element: Element, policy: PrivacyPolicy, { subtreeLimit, ...rest }?: SerializeElementOptions): AppDomSnapshot;
40
+ /** The masked snapshot of a page from its application root (default: at most 5000 nodes, 64 levels, 512 KiB). */
41
+ export declare function serializePage(root: Element, policy: PrivacyPolicy, limits?: Partial<AppDomLimits>): AppDomSnapshot;
42
+ //# sourceMappingURL=serialize.d.ts.map
@@ -0,0 +1,90 @@
1
+ // `app.dom@1`: the masked page snapshot, pure data produced only through the privacy policy (FEEDBACK.md, "Application elements").
2
+ // Light DOM only: shadow roots are omitted and iframes are not entered. Form values are never read.
3
+ import { allowedAttributes, inVisibleRegion, isAlwaysMasked, isExcluded, maskText, normalizeText } from './policy.js';
4
+ export const PAGE_LIMITS = Object.freeze({ maxNodes: 5000, maxDepth: 64, maxBytes: 512 * 1024 });
5
+ export const ELEMENT_LIMITS = Object.freeze({ maxNodes: 50, maxDepth: 16, maxBytes: 2048 });
6
+ /** Never serialized: not page content, or carries no text people see. */
7
+ const SKIPPED = new Set(['head', 'title', 'meta', 'link', 'base', 'script', 'style', 'noscript', 'template']);
8
+ /** Serialized as a leaf: their content is another document or opaque. */
9
+ const LEAVES = new Set(['iframe', 'frame', 'object', 'embed']);
10
+ const ELEMENT_NODE = 1, TEXT_NODE = 3, CDATA_NODE = 4;
11
+ const encoder = new TextEncoder();
12
+ const bytes = (value) => encoder.encode(value).length;
13
+ function sameTagIndex(element) {
14
+ let index = 0;
15
+ for (let sibling = element.previousElementSibling; sibling; sibling = sibling.previousElementSibling)
16
+ if (sibling.localName === element.localName)
17
+ index++;
18
+ return index;
19
+ }
20
+ function ownText(element, policy) {
21
+ const parts = [];
22
+ for (const child of Array.from(element.childNodes)) {
23
+ if (child.nodeType !== TEXT_NODE && child.nodeType !== CDATA_NODE)
24
+ continue;
25
+ const text = normalizeText(child.nodeValue ?? '');
26
+ if (text)
27
+ parts.push(text);
28
+ }
29
+ const text = parts.join(' ');
30
+ if (!text)
31
+ return '';
32
+ return !isAlwaysMasked(element) && inVisibleRegion(element, policy) ? text : maskText(text);
33
+ }
34
+ const skip = (element) => SKIPPED.has(element.localName) || isExcluded(element);
35
+ function serialize(start, policy, limits) {
36
+ let nodes = 0, used = 0, truncated = false;
37
+ // Pre-order walk; once a node or byte limit is hit the walk stops, so the result is a deterministic document-order prefix.
38
+ const visit = (element, depth) => {
39
+ const own = { tag: element.localName, attrs: allowedAttributes(element, policy), text: ownText(element, policy), children: [], index: sameTagIndex(element) };
40
+ // Canonical JSON of the node with no children, plus a separating comma: the sum over nodes bounds the whole tree's JSON.
41
+ const cost = bytes(JSON.stringify({ attrs: own.attrs, children: [], index: own.index, tag: own.tag, text: own.text })) + 1;
42
+ if (nodes + 1 > limits.maxNodes || used + cost > limits.maxBytes) {
43
+ truncated = true;
44
+ return undefined;
45
+ }
46
+ nodes++;
47
+ used += cost;
48
+ if (LEAVES.has(own.tag))
49
+ return own;
50
+ for (const child of Array.from(element.children)) {
51
+ if (truncated)
52
+ break;
53
+ if (child.nodeType !== ELEMENT_NODE || skip(child))
54
+ continue;
55
+ if (depth + 1 > limits.maxDepth) {
56
+ truncated = true;
57
+ break;
58
+ }
59
+ const node = visit(child, depth + 1);
60
+ if (node)
61
+ own.children.push(node);
62
+ }
63
+ return own;
64
+ };
65
+ const root = skip(start) ? null : (visit(start, 0) ?? null);
66
+ return deepFreeze({ format: 'app.dom@1', root, truncated, nodes, limits: Object.freeze({ ...limits }) });
67
+ }
68
+ function deepFreeze(value) {
69
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
70
+ Object.freeze(value);
71
+ for (const child of Object.values(value))
72
+ deepFreeze(child);
73
+ }
74
+ return value;
75
+ }
76
+ function limitsFrom(defaults, options) {
77
+ const limits = { ...defaults, ...options };
78
+ for (const [name, value] of Object.entries(limits))
79
+ if (!Number.isInteger(value) || value < 0)
80
+ throw new RangeError(`${name} must be a non-negative integer`);
81
+ return limits;
82
+ }
83
+ /** The masked snapshot of one element and its subtree (default: at most 50 nodes, 16 levels, 2 KiB). */
84
+ export function serializeElement(element, policy, { subtreeLimit = ELEMENT_LIMITS.maxNodes, ...rest } = {}) {
85
+ return serialize(element, policy, limitsFrom(ELEMENT_LIMITS, { maxNodes: subtreeLimit, ...rest }));
86
+ }
87
+ /** The masked snapshot of a page from its application root (default: at most 5000 nodes, 64 levels, 512 KiB). */
88
+ export function serializePage(root, policy, limits = {}) {
89
+ return serialize(root, policy, limitsFrom(PAGE_LIMITS, limits));
90
+ }
@@ -0,0 +1,36 @@
1
+ export interface SpeechWord {
2
+ readonly start: number;
3
+ readonly end: number;
4
+ readonly text: string;
5
+ }
6
+ /** Seconds from the audio start. `words`, when the transcription gives word timings, lets a segment split at pointer changes. */
7
+ export interface SpeechSegment {
8
+ readonly start: number;
9
+ readonly end: number;
10
+ readonly text: string;
11
+ readonly words?: readonly SpeechWord[];
12
+ }
13
+ /** `t` on the page clock; `target` an index into the session's element list, or null over nothing pickable. */
14
+ export interface PointerSample {
15
+ readonly t: number;
16
+ readonly target: number | null;
17
+ }
18
+ export interface PinMark {
19
+ readonly t: number;
20
+ readonly target: number;
21
+ }
22
+ export interface AlignedSpeech {
23
+ readonly target: number | null;
24
+ readonly text: string;
25
+ readonly at: number;
26
+ readonly confidence: 'dwell' | 'pin' | 'none';
27
+ }
28
+ /** How long after a segment ends a pin still claims it (milliseconds). */
29
+ export declare const PIN_AFTER_MS = 1000;
30
+ export declare function alignSpeech(input: {
31
+ readonly segments: readonly SpeechSegment[];
32
+ readonly audioStartedAt: number;
33
+ readonly pointer: readonly PointerSample[];
34
+ readonly pins: readonly PinMark[];
35
+ }): readonly AlignedSpeech[];
36
+ //# sourceMappingURL=align.d.ts.map
@@ -0,0 +1,122 @@
1
+ // Voice and pointer alignment: what a person says while pointing at an element attaches to that element.
2
+ //
3
+ // One clock for everything: `performance.now()` milliseconds of the page. Transcription segments are in seconds from the first
4
+ // audio sample; `audioStartedAt` (from `VoiceCapture.stop`) puts that sample on the page clock. Pure: no DOM, no timers.
5
+ //
6
+ // The rule, applied to each segment (or to each word group, see below):
7
+ // 1. The segment covers the page-time window [a, b] = [audioStartedAt + start × 1000, audioStartedAt + end × 1000].
8
+ // 2. Pin: a pin placed inside [a, b] wins (the earliest one). Otherwise a pin placed in the second after the segment wins
9
+ // (b, b + PIN_AFTER_MS], unless the next segment has already started, so a pin follows "this one is wrong" said just
10
+ // before the click but never steals the next sentence's pin. Confidence 'pin'.
11
+ // 3. Dwell: otherwise the element the pointer stayed over longest inside [a, b] (ties: the one reached first). Confidence 'dwell'.
12
+ // Pointer samples are change points: a sample holds until the next one, the last one holds on. Before the first sample the
13
+ // pointer is unknown, as over nothing. The session must send `{ target: null }` when the pointer leaves the page or crosses
14
+ // something not pickable, or a gap reads as dwelling on the last element.
15
+ // 4. Narration: the pointer over nothing for the whole window (and no pin) gives target null, confidence 'none'.
16
+ // 5. Word timings: when a segment carries `words`, it is split where the pointer moves to another element (each word placed by
17
+ // the dwell over its own time; words over nothing stay with the group before them), and each group gets rules 2–4 on its own
18
+ // window. Without word timings a segment is never split.
19
+ //
20
+ // The clock was measured, not assumed: `npm run feedback:voice-sync` (tools/feedback-voice-sync.mjs) records beeps in headless
21
+ // Chromium while the pointer moves; the chosen `startedAt` was within 9 ms of an independent reader of the microphone (see
22
+ // .cache/evidence/feedback-voice/RESULT.md), far below the 150 ms target and a segment's own length, so no window is widened.
23
+ /** How long after a segment ends a pin still claims it (milliseconds). */
24
+ export const PIN_AFTER_MS = 1000;
25
+ const finite = (value) => typeof value === 'number' && Number.isFinite(value);
26
+ /** Milliseconds the pointer spent over each element inside [a, b], with the time each was first reached there. */
27
+ function dwell(pointer, a, b) {
28
+ const out = new Map();
29
+ for (let i = 0; i < pointer.length; i++) {
30
+ const sample = pointer[i];
31
+ const from = Math.max(a, sample.t);
32
+ const next = i + 1 < pointer.length ? pointer[i + 1].t : Infinity;
33
+ const until = Math.min(b, next);
34
+ // A zero-length window (a word or segment with no duration) takes the sample holding at that instant.
35
+ const holds = a === b ? sample.t <= a && next > a : until > from;
36
+ if (sample.target === null || !holds)
37
+ continue;
38
+ const seen = out.get(sample.target);
39
+ if (seen)
40
+ seen.ms += until - from;
41
+ else
42
+ out.set(sample.target, { ms: until - from, first: from });
43
+ }
44
+ return out;
45
+ }
46
+ function dwellTarget(pointer, a, b) {
47
+ let best = null, bestMs = -1, bestFirst = Infinity;
48
+ for (const [target, { ms, first }] of dwell(pointer, a, b)) {
49
+ if (ms > bestMs || (ms === bestMs && first < bestFirst)) {
50
+ best = target;
51
+ bestMs = ms;
52
+ bestFirst = first;
53
+ }
54
+ }
55
+ return best;
56
+ }
57
+ function pinTarget(pins, a, b, nextStart) {
58
+ let inside = null, after = null;
59
+ const limit = Math.min(b + PIN_AFTER_MS, Math.max(b, nextStart));
60
+ for (const pin of pins) {
61
+ if (pin.t >= a && pin.t <= b) {
62
+ if (!inside || pin.t < inside.t)
63
+ inside = pin;
64
+ }
65
+ else if (pin.t > b && pin.t <= limit) {
66
+ if (!after || pin.t < after.t)
67
+ after = pin;
68
+ }
69
+ }
70
+ return (inside ?? after)?.target ?? null;
71
+ }
72
+ /** Word groups of one segment: consecutive words over the same element; words over nothing join the group before them. */
73
+ function groups(segment, base, pointer) {
74
+ const words = (segment.words ?? []).filter(word => finite(word.start) && finite(word.end) && word.text.trim());
75
+ if (words.length < 2)
76
+ return null;
77
+ const out = [];
78
+ for (const word of words) {
79
+ const target = dwellTarget(pointer, base + word.start * 1000, base + Math.max(word.start, word.end) * 1000);
80
+ const last = out[out.length - 1];
81
+ if (last && (target === null || target === last.target))
82
+ last.words.push(word);
83
+ else if (last && last.target === null) {
84
+ last.target = target;
85
+ last.words.push(word);
86
+ }
87
+ else
88
+ out.push({ target, words: [word] });
89
+ }
90
+ if (out.length < 2)
91
+ return null;
92
+ return out.map(group => {
93
+ const first = group.words[0], last = group.words[group.words.length - 1];
94
+ return { a: base + first.start * 1000, b: base + Math.max(first.start, last.end) * 1000, start: first.start, text: group.words.map(word => word.text.trim()).join(' ') };
95
+ });
96
+ }
97
+ export function alignSpeech(input) {
98
+ const base = input.audioStartedAt;
99
+ if (!finite(base))
100
+ return [];
101
+ const pointer = input.pointer.filter(sample => finite(sample.t)).slice().sort((x, y) => x.t - y.t);
102
+ const pins = input.pins.filter(pin => finite(pin.t));
103
+ const spans = [];
104
+ for (const segment of input.segments) {
105
+ if (!finite(segment.start) || !finite(segment.end) || !segment.text.trim())
106
+ continue;
107
+ const split = groups(segment, base, pointer);
108
+ if (split)
109
+ spans.push(...split);
110
+ else
111
+ spans.push({ a: base + segment.start * 1000, b: base + Math.max(segment.start, segment.end) * 1000, start: segment.start, text: segment.text.trim() });
112
+ }
113
+ spans.sort((x, y) => x.a - y.a);
114
+ return spans.map((span, i) => {
115
+ const next = spans.slice(i + 1).find(other => other.a > span.a)?.a ?? Infinity;
116
+ const pinned = pinTarget(pins, span.a, span.b, next);
117
+ if (pinned !== null)
118
+ return { target: pinned, text: span.text, at: span.a, confidence: 'pin' };
119
+ const target = dwellTarget(pointer, span.a, span.b);
120
+ return target === null ? { target: null, text: span.text, at: span.a, confidence: 'none' } : { target, text: span.text, at: span.a, confidence: 'dwell' };
121
+ });
122
+ }
@@ -0,0 +1,43 @@
1
+ /** Why the microphone could not be used, for a short human message: `denied` (the person or the browser refused the permission),
2
+ * `no-microphone`, `busy` (in use or could not be opened), `insecure` (http on a name other than localhost: no media devices at all),
3
+ * `unsupported` (no MediaRecorder, or it cannot record this microphone), `failed` (anything else). */
4
+ export type VoiceRefusal = 'denied' | 'no-microphone' | 'busy' | 'insecure' | 'unsupported' | 'failed';
5
+ export type VoiceStart = {
6
+ readonly kind: 'recording';
7
+ } | {
8
+ readonly kind: 'refused';
9
+ readonly reason: string;
10
+ readonly code?: VoiceRefusal;
11
+ };
12
+ export interface VoiceRecording {
13
+ readonly audio: Blob;
14
+ readonly mimeType: string;
15
+ /** Page time (`performance.now()` ms) of the first recorded sample. */
16
+ readonly startedAt: number;
17
+ readonly endedAt: number;
18
+ /** The recording reached `maxMs` and stopped on its own. */
19
+ readonly truncated: boolean;
20
+ }
21
+ export interface VoiceCapture {
22
+ readonly start: () => Promise<VoiceStart>;
23
+ /** Ends the recording and returns it; null when nothing was recorded (never started, cancelled, or empty). */
24
+ readonly stop: () => Promise<VoiceRecording | null>;
25
+ /** Ends the recording, releases the microphone and discards the audio. */
26
+ readonly cancel: () => void;
27
+ }
28
+ export interface VoiceCaptureOptions {
29
+ readonly getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
30
+ /** Longest recording in milliseconds (default 5 minutes); the recording stops there and `stop()` reports `truncated`. */
31
+ readonly maxMs?: number;
32
+ /** The page clock (default `performance.now()`). */
33
+ readonly now?: () => number;
34
+ }
35
+ export declare const DEFAULT_MAX_VOICE_MS: number;
36
+ /** Container types tried in order; the first the browser records wins. */
37
+ export declare const VOICE_MIME_TYPES: readonly ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
38
+ /** The browser's microphone, or null where the page cannot have one (insecure origin, no media devices). */
39
+ export declare function browserGetUserMedia(): ((constraints: MediaStreamConstraints) => Promise<MediaStream>) | null;
40
+ /** Short human messages, one per refusal; each says what to do next. */
41
+ export declare const VOICE_REFUSALS: Readonly<Record<VoiceRefusal, string>>;
42
+ export declare function createVoiceCapture(options?: VoiceCaptureOptions): VoiceCapture;
43
+ //# sourceMappingURL=capture.d.ts.map