inverse-interface 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +70 -2
- package/browser.js +3 -0
- package/client.d.ts +25 -0
- package/client.js +147 -0
- package/consent.d.ts +15 -0
- package/consent.js +110 -0
- package/index.d.ts +5 -0
- package/index.js +5 -0
- package/intent.d.ts +11 -0
- package/intent.js +146 -0
- package/package.json +17 -4
- package/recorder.d.ts +14 -0
- package/recorder.js +147 -0
- package/schema.d.ts +51 -0
- package/schema.js +137 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Inverse
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# inverse-interface
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The open runtime for [Inverse Interface](https://www.inverse.at/docs/interface): record what people do in your web app as semantic events, ask for their consent first, and turn a session into an intent package your coding agent can act on. MIT, zero dependencies, plain ESM. No build step needed.
|
|
4
|
+
|
|
5
|
+
## Free anywhere vs metered
|
|
6
|
+
|
|
7
|
+
| Runs free, anywhere (this package) | Metered, via the hosted API |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| The recorder, redaction, the consent prompt, the room link, the `interface-event.v1` and `intent.v1` formats, and the heuristic `buildIntent()` | Hosted sessions (`capture_session`), hosted intent packages (`intent_package`), rooms, answered asks and proposals |
|
|
10
|
+
|
|
11
|
+
You need no Inverse account to use this package. Metering happens only on Inverse's servers, when a billable artifact is produced. The hosted API accepts events from anywhere, including your own recorder, so nothing here is "unlocked" by a license.
|
|
12
|
+
|
|
13
|
+
## Quickstart: the script tag
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<script src="https://www.inverse.at/v1-0.1.0.js" integrity="sha384-…" crossorigin="anonymous"></script>
|
|
17
|
+
<script>Inverse.capture({ key: "pk_live_…", task: "Sign up and create a project" });</script>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`/v1.js` always serves the latest 1.x. Pin `/v1-<version>.js` with its `integrity` hash, listed at [inverse.at/docs/interface](https://www.inverse.at/docs/interface). The `pk_` key is publishable, and the API accepts it only from your project's allowlisted domains.
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
Inverse.on("event", (e) => console.log(e.kind, e.target));
|
|
24
|
+
Inverse.on("end", (pkg) => console.log(pkg)); // intent.v1 from the hosted API
|
|
25
|
+
const capture = await Inverse.capture({ key, task, solo: true }); // null if they chose "Not now"
|
|
26
|
+
await capture?.end();
|
|
27
|
+
Inverse.room({ url: "https://your-app.com" }); // open an Inverse room around a URL
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## npm
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm i inverse-interface
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { createRecorder } from "inverse-interface/recorder";
|
|
38
|
+
import { buildIntent } from "inverse-interface/intent";
|
|
39
|
+
|
|
40
|
+
const events = [];
|
|
41
|
+
const recorder = createRecorder({ emit: (e) => events.push({ ...e, session: "local" }) }).start();
|
|
42
|
+
// later: no network, no account
|
|
43
|
+
console.log(buildIntent(events, { task: "Check out" }));
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Import | What it is |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `inverse-interface/recorder` | `createRecorder({ emit })` → `start()`, `stop()`, `pause(on)`. Navigation (including SPA history), clicks with accessible label, role and short selector, form submits, errors, rage clicks (3+ in 600ms), dead clicks (no DOM change or navigation within 1s), scroll depth (25/50/75/100). |
|
|
49
|
+
| `inverse-interface/consent` | `createConsent({ app })` → `ask()`, `showIndicator({ onPause, onStop })`. A shadow-DOM prompt with equal Allow and Not now buttons, remembered for the site. |
|
|
50
|
+
| `inverse-interface/client` | `createClient({ key })` for the hosted API, and the page-level `capture`, `room` and `on`. |
|
|
51
|
+
| `inverse-interface/intent` | `buildIntent(events, { task })` → `intent.v1`. Deterministic, local. |
|
|
52
|
+
| `inverse-interface/schema` | `eventErrors`, `redactEvent`, `intentErrors` and the JSON Schemas. |
|
|
53
|
+
|
|
54
|
+
## Bring your own events
|
|
55
|
+
|
|
56
|
+
Already capture sessions some other way? Send `interface-event.v1` events with a secret key and get an intent package back. Inverse doesn't need to have captured them.
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
curl https://www.inverse.at/v1/intent-packages \
|
|
60
|
+
-H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \
|
|
61
|
+
-d '{"task":"Check out","events":[{"v":1,"t":1793491200000,"session":"s1","kind":"click","path":"/cart","target":{"label":"Pay","role":"button"}}]}'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Privacy
|
|
65
|
+
|
|
66
|
+
- **Consent first.** Nothing is recorded until the person chooses Allow. Every batch carries the consent record, and the API rejects events without it.
|
|
67
|
+
- **Never input values.** The recorder never reads `.value`, nor the text of fields or editable content. A submit says that a form was sent, not what was in it.
|
|
68
|
+
- **Redaction at source.** It skips `input[type=password]`, payment fields (`autocomplete="cc-*"`), and everything inside `[data-inverse-redact]`. Only the path is kept, never the query string or hash. The client redacts again with `redactEvent`, and so does the server.
|
|
69
|
+
- **Who sees it:** the team that runs the app and their coding agent, as "Speaker A". A persistent indicator lets the person pause or stop at any time.
|
|
70
|
+
|
|
71
|
+
Docs: [inverse.at/docs/interface](https://www.inverse.at/docs/interface)
|
package/browser.js
ADDED
package/client.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RecordedEvent } from "./recorder.js";
|
|
2
|
+
import type { SavedConsent } from "./consent.js";
|
|
3
|
+
export const ORIGIN: string;
|
|
4
|
+
export const MAX_EVENTS: number, MAX_BYTES: number, BEACON_BYTES: number, FLUSH_MS: number;
|
|
5
|
+
export type Session = { id: string; link?: string; upload?: string };
|
|
6
|
+
export function batches<T>(events: T[], maxEvents?: number, maxBytes?: number): T[][];
|
|
7
|
+
export function roomLink(url: string, origin?: string): string;
|
|
8
|
+
export type Client = {
|
|
9
|
+
readonly session: Session | null;
|
|
10
|
+
start(options?: { task?: string; mode?: "live" | "solo"; meta?: Record<string, unknown> }): Promise<Session>;
|
|
11
|
+
push(event: RecordedEvent): void;
|
|
12
|
+
flush(options?: { beacon?: boolean }): Promise<void>;
|
|
13
|
+
end(): Promise<unknown>;
|
|
14
|
+
};
|
|
15
|
+
export function createClient(options: {
|
|
16
|
+
key: string;
|
|
17
|
+
origin?: string;
|
|
18
|
+
consent: () => SavedConsent | null;
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
beacon?: (url: string, body: string) => boolean;
|
|
21
|
+
}): Client;
|
|
22
|
+
export function on(type: "consent" | "session" | "event" | "end" | "error", fn: (data: any) => void): () => void;
|
|
23
|
+
export function room(options?: { url?: string; origin?: string }): string;
|
|
24
|
+
export type Capture = { session: Session | null; pause(on?: boolean): void; end(): Promise<unknown> };
|
|
25
|
+
export function capture(options: { key: string; task?: string; solo?: boolean; origin?: string; app?: string; meta?: Record<string, unknown> }): Promise<Capture | null>;
|
package/client.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* inverse-interface/client: sends recorded events to the hosted Inverse API (metered there, never here).
|
|
3
|
+
* Inverse.capture() = consent first, then a session, then the recorder, batched every few seconds and
|
|
4
|
+
* flushed with sendBeacon when the page goes away. Inverse.room() opens an Inverse room around a URL.
|
|
5
|
+
*/
|
|
6
|
+
import { createRecorder } from "./recorder.js";
|
|
7
|
+
import { createConsent, consentPayload } from "./consent.js";
|
|
8
|
+
import { redactEvent } from "./schema.js";
|
|
9
|
+
|
|
10
|
+
export const ORIGIN = "https://www.inverse.at";
|
|
11
|
+
export const MAX_EVENTS = 500, MAX_BYTES = 256 * 1024, BEACON_BYTES = 60 * 1024, FLUSH_MS = 5000;
|
|
12
|
+
const size = (x) => new TextEncoder().encode(JSON.stringify(x)).length;
|
|
13
|
+
|
|
14
|
+
/** Splits events into batches of ≤ maxEvents and ≤ maxBytes of JSON (1 KB kept for the envelope). Oversized single events are dropped. */
|
|
15
|
+
export function batches(events, maxEvents = MAX_EVENTS, maxBytes = MAX_BYTES) {
|
|
16
|
+
const out = [], room = maxBytes - 1024;
|
|
17
|
+
let batch = [], bytes = 0;
|
|
18
|
+
for (const e of events) {
|
|
19
|
+
const n = size(e) + 1;
|
|
20
|
+
if (n > room) continue;
|
|
21
|
+
if (batch.length >= maxEvents || bytes + n > room) out.push(batch), (batch = []), (bytes = 0);
|
|
22
|
+
batch.push(e);
|
|
23
|
+
bytes += n;
|
|
24
|
+
}
|
|
25
|
+
if (batch.length) out.push(batch);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A link that opens an Inverse room around `url` (the landing page's paste-a-URL flow). */
|
|
30
|
+
export const roomLink = (url, origin = ORIGIN) => `${origin}/?url=${encodeURIComponent(url)}`;
|
|
31
|
+
|
|
32
|
+
/** createClient({ key, origin }) → { start, push, flush, end }. `key` is a publishable pk_… key. */
|
|
33
|
+
/** The session id a solo capture link carries, if this page was opened from one. */
|
|
34
|
+
export function linkedSession(href = globalThis.location?.href) {
|
|
35
|
+
try {
|
|
36
|
+
const id = href ? new URL(href).searchParams.get("inverse_session") : null;
|
|
37
|
+
return id && /^ses_[a-z0-9]{24}$/.test(id) ? id : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createClient({ key, origin = ORIGIN, consent, fetch = globalThis.fetch.bind(globalThis), beacon = globalThis.navigator?.sendBeacon?.bind(globalThis.navigator) }) {
|
|
44
|
+
let session = null, queue = [];
|
|
45
|
+
const call = async (path, body, opts = {}) => {
|
|
46
|
+
const res = await fetch(origin + path, { method: "POST", keepalive: opts.keepalive, headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, body: JSON.stringify(body ?? {}) });
|
|
47
|
+
const json = await res.json().catch(() => ({}));
|
|
48
|
+
if (!res.ok) throw Object.assign(new Error(json.error ?? `Inverse ${res.status}`), { status: res.status });
|
|
49
|
+
return json;
|
|
50
|
+
};
|
|
51
|
+
const client = {
|
|
52
|
+
get session() {
|
|
53
|
+
return session;
|
|
54
|
+
},
|
|
55
|
+
/** Joins the session a solo capture link names (`?inverse_session=ses_…`), else POST /v1/sessions → { id, link?, upload }. */
|
|
56
|
+
async start({ task, mode = "live", meta, join = linkedSession() } = {}) {
|
|
57
|
+
session = join ? { id: join, mode: "solo", task } : await call("/v1/sessions", { task, mode, meta });
|
|
58
|
+
return session;
|
|
59
|
+
},
|
|
60
|
+
push(event) {
|
|
61
|
+
queue.push(event);
|
|
62
|
+
},
|
|
63
|
+
/** Sends what's queued. With `{ beacon: true }` (page going away) it uses sendBeacon, keyed by ?key= since beacons carry no headers. */
|
|
64
|
+
async flush({ beacon: away = false } = {}) {
|
|
65
|
+
const grant = consentPayload(consent());
|
|
66
|
+
if (!session || !queue.length || !grant) return;
|
|
67
|
+
const events = queue.splice(0).map((e) => redactEvent({ ...e, session: session.id })); // redacted again here and on the server
|
|
68
|
+
const upload = session.upload ?? `/v1/sessions/${session.id}/events`;
|
|
69
|
+
for (const batch of batches(events, MAX_EVENTS, away ? BEACON_BYTES : MAX_BYTES)) {
|
|
70
|
+
const body = { consent: grant, events: batch };
|
|
71
|
+
// A text/plain beacon needs no CORS preflight; the API reads the body as JSON either way.
|
|
72
|
+
if (away && beacon?.(`${origin}${upload}?key=${encodeURIComponent(key)}`, JSON.stringify(body))) continue;
|
|
73
|
+
await call(upload, body, { keepalive: away }).catch((e) => (away ? undefined : (queue.unshift(...batch), Promise.reject(e))));
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
/** Flushes, then POST /v1/sessions/:id/end, which builds the intent package on the server. */
|
|
77
|
+
async end() {
|
|
78
|
+
if (!session) return null;
|
|
79
|
+
await client.flush();
|
|
80
|
+
const result = await call(`/v1/sessions/${session.id}/end`);
|
|
81
|
+
session = null;
|
|
82
|
+
return result;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
return client;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── The page-level API (window.Inverse in the script build) ──
|
|
89
|
+
|
|
90
|
+
const listeners = new Map();
|
|
91
|
+
const fire = (type, data) => listeners.get(type)?.forEach((fn) => fn(data));
|
|
92
|
+
|
|
93
|
+
/** Inverse.on("consent" | "session" | "event" | "end" | "error", fn) → unsubscribe. */
|
|
94
|
+
export function on(type, fn) {
|
|
95
|
+
if (!listeners.has(type)) listeners.set(type, new Set());
|
|
96
|
+
listeners.get(type).add(fn);
|
|
97
|
+
return () => listeners.get(type).delete(fn);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Inverse.room({ url }): opens an Inverse room around `url` (default: this page) in a new tab, and returns the link. */
|
|
101
|
+
export function room({ url = globalThis.location?.href, origin = ORIGIN } = {}) {
|
|
102
|
+
const link = roomLink(url, origin);
|
|
103
|
+
globalThis.open?.(link, "_blank", "noopener");
|
|
104
|
+
return link;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Inverse.capture({ key, task, solo, origin, app }) → Promise<{ session, pause, end } | null>.
|
|
109
|
+
* Asks for consent first; resolves null when the person says Not now. `solo` makes a solo (unmoderated) session.
|
|
110
|
+
*/
|
|
111
|
+
export async function capture({ key, task, solo = false, origin = ORIGIN, app, meta } = {}) {
|
|
112
|
+
if (!/^pk_(test|live)_/.test(key ?? "")) throw new Error("Inverse.capture needs a publishable key (pk_test_… or pk_live_…)");
|
|
113
|
+
const consent = createConsent({ app });
|
|
114
|
+
const allowed = await consent.ask();
|
|
115
|
+
fire("consent", { granted: allowed });
|
|
116
|
+
if (!allowed) return null;
|
|
117
|
+
const client = createClient({ key, origin, consent: consent.get });
|
|
118
|
+
try {
|
|
119
|
+
fire("session", await client.start({ task, mode: solo ? "solo" : "live", meta }));
|
|
120
|
+
} catch (e) {
|
|
121
|
+
fire("error", e);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const recorder = createRecorder({ emit: (e) => (client.push(e), fire("event", e)) });
|
|
125
|
+
const report = (e) => fire("error", e);
|
|
126
|
+
const timer = setInterval(() => client.flush().catch(report), FLUSH_MS);
|
|
127
|
+
const away = () => void client.flush({ beacon: true });
|
|
128
|
+
const hidden = () => document.visibilityState === "hidden" && away();
|
|
129
|
+
addEventListener("pagehide", away);
|
|
130
|
+
document.addEventListener("visibilitychange", hidden);
|
|
131
|
+
const stop = () => {
|
|
132
|
+
recorder.stop();
|
|
133
|
+
clearInterval(timer);
|
|
134
|
+
removeEventListener("pagehide", away);
|
|
135
|
+
document.removeEventListener("visibilitychange", hidden);
|
|
136
|
+
consent.remove();
|
|
137
|
+
};
|
|
138
|
+
const end = async () => {
|
|
139
|
+
stop();
|
|
140
|
+
const result = await client.end().catch((e) => (report(e), null));
|
|
141
|
+
fire("end", result);
|
|
142
|
+
return result;
|
|
143
|
+
};
|
|
144
|
+
consent.showIndicator({ onPause: (p) => recorder.pause(p), onStop: () => void end() });
|
|
145
|
+
recorder.start();
|
|
146
|
+
return { session: client.session, pause: (p = true) => recorder.pause(p), end };
|
|
147
|
+
}
|
package/consent.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type SavedConsent = { answer: "allow" | "no"; at: number };
|
|
2
|
+
export type ConsentPayload = { granted: true; at: number; scope: string[] };
|
|
3
|
+
type Store = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
4
|
+
export const CONSENT_KEY: string;
|
|
5
|
+
export const SCOPE: string[];
|
|
6
|
+
export function loadConsent(storage: Store | null | undefined): SavedConsent | null;
|
|
7
|
+
export function saveConsent(storage: Store | null | undefined, answer: "allow" | "no" | null, at?: number): SavedConsent | null;
|
|
8
|
+
export function consentPayload(saved: SavedConsent | null): ConsentPayload | null;
|
|
9
|
+
export type Consent = {
|
|
10
|
+
get(): SavedConsent | null;
|
|
11
|
+
ask(force?: boolean): Promise<boolean>;
|
|
12
|
+
showIndicator(handlers?: { onPause?: (paused: boolean) => void; onStop?: () => void }): void;
|
|
13
|
+
remove(): void;
|
|
14
|
+
};
|
|
15
|
+
export function createConsent(options?: { app?: string; win?: Window; storage?: Store | null }): Consent;
|
package/consent.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* inverse-interface/consent: a plain-language ask in its own shadow DOM, then a small indicator with Pause.
|
|
3
|
+
* Nothing is recorded before someone says Allow. The answer is remembered for this site (localStorage is per origin).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const CONSENT_KEY = "inverse:interface:consent";
|
|
7
|
+
export const SCOPE = ["navigation", "clicks", "submits", "errors", "scroll"];
|
|
8
|
+
|
|
9
|
+
/** The saved answer, `{ answer: "allow" | "no", at }`, or null when never asked or unreadable. */
|
|
10
|
+
export function loadConsent(storage) {
|
|
11
|
+
try {
|
|
12
|
+
const saved = JSON.parse(storage?.getItem(CONSENT_KEY) ?? "null");
|
|
13
|
+
return saved?.answer === "allow" || saved?.answer === "no" ? { answer: saved.answer, at: Number(saved.at) || 0 } : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function saveConsent(storage, answer, at = Date.now()) {
|
|
20
|
+
try {
|
|
21
|
+
if (answer) storage?.setItem(CONSENT_KEY, JSON.stringify({ answer, at }));
|
|
22
|
+
else storage?.removeItem(CONSENT_KEY);
|
|
23
|
+
} catch {}
|
|
24
|
+
return answer ? { answer, at } : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The consent block every event batch carries. */
|
|
28
|
+
export const consentPayload = (saved) => (saved?.answer === "allow" ? { granted: true, at: saved.at, scope: SCOPE } : null);
|
|
29
|
+
|
|
30
|
+
const CSS = `
|
|
31
|
+
:host{all:initial}
|
|
32
|
+
*{box-sizing:border-box;font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
33
|
+
.card,.pill{position:fixed;z-index:2147483647;right:16px;bottom:16px;color:#0a0a0a;background:#fff;border:1px solid #0001;box-shadow:0 14px 32px -16px #111a2c80}
|
|
34
|
+
.card{width:min(24rem,calc(100vw - 32px));border-radius:16px;padding:16px;animation:in .18s ease-out}
|
|
35
|
+
.pill{display:flex;align-items:center;gap:8px;border-radius:999px;padding:4px 4px 4px 12px;font-size:12px}
|
|
36
|
+
p{margin:0 0 8px}.t{font-weight:600}b{font:inherit;font-weight:600}.s{font-size:12px;color:#525252}
|
|
37
|
+
.row{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:12px}
|
|
38
|
+
button{cursor:pointer;border:0;border-radius:999px;background:#f0f0f0;color:inherit;padding:8px 12px;font-weight:500;min-height:32px}
|
|
39
|
+
.pill button{font-size:12px;padding:4px 10px;min-height:28px}
|
|
40
|
+
button:hover{background:#e5e5e5}button:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
|
|
41
|
+
.dot{width:8px;height:8px;border-radius:50%;background:#16a34a}.dot.off{background:#a3a3a3}
|
|
42
|
+
@media (pointer:coarse){button{min-height:44px}}
|
|
43
|
+
@media (prefers-color-scheme:dark){.card,.pill{color:#fafafa;background:#171717;border-color:#fff2}.s{color:#a3a3a3}button{background:#262626}button:hover{background:#333}}
|
|
44
|
+
@media (prefers-reduced-motion:reduce){.card{animation:none}}
|
|
45
|
+
@keyframes in{from{opacity:0;transform:translateY(8px)}}`;
|
|
46
|
+
|
|
47
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* createConsent({ app }) → { ask(): Promise<boolean>, get(), showIndicator({ onPause, onStop }), remove() }.
|
|
51
|
+
* `app` names the site in the copy ("Acme"); it defaults to the hostname.
|
|
52
|
+
*/
|
|
53
|
+
export function createConsent({ app, win = globalThis.window, storage = (() => { try { return win.localStorage; } catch { return null; } })() } = {}) {
|
|
54
|
+
const name = esc(app ?? win.location.hostname);
|
|
55
|
+
let host;
|
|
56
|
+
const mount = (html) => {
|
|
57
|
+
host?.remove();
|
|
58
|
+
host = win.document.createElement("div");
|
|
59
|
+
host.setAttribute("data-inverse-redact", ""); // the recorder never records its own UI
|
|
60
|
+
const root = host.attachShadow({ mode: "open" });
|
|
61
|
+
root.innerHTML = `<style>${CSS}</style>${html}`;
|
|
62
|
+
win.document.body.append(host);
|
|
63
|
+
return root;
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
get: () => loadConsent(storage),
|
|
67
|
+
/** Resolves true when allowed. Asks only when never answered; a remembered "Not now" resolves false. */
|
|
68
|
+
ask(force = false) {
|
|
69
|
+
const saved = loadConsent(storage);
|
|
70
|
+
if (saved && !force) return Promise.resolve(saved.answer === "allow");
|
|
71
|
+
const before = win.document.activeElement;
|
|
72
|
+
const root = mount(`<section class="card" role="dialog" aria-modal="false" aria-labelledby="t" aria-describedby="d">
|
|
73
|
+
<p class="t" id="t">Let ${name}'s team see how you use this page?</p>
|
|
74
|
+
<div id="d"><p>Where you click and which pages you visit become notes the team can use to fix what gets in your way.</p>
|
|
75
|
+
<p class="s"><b>Captured:</b> pages you visit, the buttons and links you click, that a form was sent, errors, and how far you scroll.</p>
|
|
76
|
+
<p class="s"><b>Never captured:</b> what you type, passwords, payment details, or your screen.</p>
|
|
77
|
+
<p class="s">The team that runs this app and their coding agent see it, as “Speaker A”. You can pause any time.</p></div>
|
|
78
|
+
<div class="row"><button data-a="allow">Allow</button><button data-a="no">Not now</button></div></section>`);
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const answer = (a) => {
|
|
81
|
+
saveConsent(storage, a);
|
|
82
|
+
host.remove();
|
|
83
|
+
before?.focus?.({ preventScroll: true });
|
|
84
|
+
resolve(a === "allow");
|
|
85
|
+
};
|
|
86
|
+
root.querySelectorAll("button").forEach((b) => (b.onclick = () => answer(b.dataset.a)));
|
|
87
|
+
root.querySelector("section").onkeydown = (e) => e.key === "Escape" && answer("no");
|
|
88
|
+
root.querySelector("button").focus({ preventScroll: true });
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
/** The persistent indicator: a dot, what's happening, Pause/Resume, and Stop (remembered as "Not now"). */
|
|
92
|
+
showIndicator({ onPause, onStop } = {}) {
|
|
93
|
+
const root = mount(`<div class="pill" role="status" aria-live="polite"><span class="dot" aria-hidden="true"></span><span class="m">Sharing clicks with ${name}</span><button data-a="pause">Pause</button><button data-a="stop">Stop</button></div>`);
|
|
94
|
+
let paused = false;
|
|
95
|
+
root.querySelector('[data-a="pause"]').onclick = (e) => {
|
|
96
|
+
paused = !paused;
|
|
97
|
+
e.currentTarget.textContent = paused ? "Resume" : "Pause";
|
|
98
|
+
root.querySelector(".dot").classList.toggle("off", paused);
|
|
99
|
+
root.querySelector(".m").textContent = paused ? "Paused. Nothing is shared." : `Sharing clicks with ${app ?? win.location.hostname}`;
|
|
100
|
+
onPause?.(paused);
|
|
101
|
+
};
|
|
102
|
+
root.querySelector('[data-a="stop"]').onclick = () => {
|
|
103
|
+
saveConsent(storage, "no");
|
|
104
|
+
host.remove();
|
|
105
|
+
onStop?.();
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
remove: () => host?.remove(),
|
|
109
|
+
};
|
|
110
|
+
}
|
package/index.d.ts
ADDED
package/index.js
ADDED
package/intent.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Intent, InterfaceEvent } from "./schema.js";
|
|
2
|
+
export type { Intent, InterfaceEvent } from "./schema.js";
|
|
3
|
+
|
|
4
|
+
export declare function classify(line: string): "friction" | "request" | "question" | "praise" | null;
|
|
5
|
+
/** 0 → "Speaker A", 25 → "Speaker Z", 26 → "Speaker AA". */
|
|
6
|
+
export declare function aliasLabel(index: number): string;
|
|
7
|
+
/** interface-event.v1[] → intent.v1. Deterministic heuristics; `quality.billable` is the zero-rating gate. */
|
|
8
|
+
export declare function buildIntent(
|
|
9
|
+
events: readonly unknown[] | InterfaceEvent[],
|
|
10
|
+
options?: { task?: string; id?: string; session?: string | null; now?: number },
|
|
11
|
+
): Intent;
|
package/intent.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The pure intent builder: interface-event.v1[] → intent.v1. Deterministic heuristics, no dependencies, runs anywhere.
|
|
2
|
+
// ponytail: keyword and event-pattern rules only; the hosted LLM pass for goal, outcome and friction comes later
|
|
3
|
+
// (docs/interaction-primitives-plan.md, weeks 3-4) and will refine this package, not replace the format.
|
|
4
|
+
import { eventErrors, redactEvent } from "./schema.js";
|
|
5
|
+
|
|
6
|
+
// Same rules as inverse-embed's room classifier (context.js `classify`); a parity test keeps them in step.
|
|
7
|
+
const RULES = [
|
|
8
|
+
["friction", /\b(can[’']?t|cannot|doesn[’']?t work|does not work|not working|won[’']?t|stuck|broken|confus\w*|ugh|bug)\b/i],
|
|
9
|
+
["request", /\b(can you|could you|should|i want|i[’']?d like|add|wish)\b/i],
|
|
10
|
+
["question", /\?|\b(how|why|where|can i)\b/i],
|
|
11
|
+
["praise", /\b(love|nice|great|wow|awesome|amazing)\b/i],
|
|
12
|
+
];
|
|
13
|
+
/** A said line → "friction" | "request" | "question" | "praise" | null. First match wins. */
|
|
14
|
+
export const classify = (line) => RULES.find(([, re]) => re.test(line))?.[0] ?? null;
|
|
15
|
+
|
|
16
|
+
/** 0 → "Speaker A", 25 → "Speaker Z", 26 → "Speaker AA". */
|
|
17
|
+
export function aliasLabel(index) {
|
|
18
|
+
let letters = "";
|
|
19
|
+
for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26)) letters = String.fromCharCode(65 + ((n - 1) % 26)) + letters;
|
|
20
|
+
return `Speaker ${letters}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DONE = /^(done|complete|completed|achieved|success|succeeded)$/i;
|
|
24
|
+
const GAVE_UP = /^(abandon|abandoned|failed|gave_up|give_up|skipped|cancel|cancelled|canceled)$/i;
|
|
25
|
+
const FRICTION_EVENTS = new Set(["error", "rage_click", "dead_click"]);
|
|
26
|
+
const MERGE_MS = 30_000; // repeats of the same friction within this window are one item with more evidence
|
|
27
|
+
const CAP = { timeline: 1_000, signals: 500, friction: 200 };
|
|
28
|
+
|
|
29
|
+
const label = (e) => e.target?.label || e.target?.selector || e.target?.role || "";
|
|
30
|
+
const quoteOf = (s) => (s.length > 80 ? `${s.slice(0, 77)}…` : s);
|
|
31
|
+
function describe(e) {
|
|
32
|
+
const d = e.data ?? {};
|
|
33
|
+
switch (e.kind) {
|
|
34
|
+
case "navigate": return e.path || "page";
|
|
35
|
+
case "error": return `${typeof d.message === "string" ? d.message.slice(0, 200) : "error"}${e.path ? ` (on ${e.path})` : ""}`;
|
|
36
|
+
case "scroll_depth": return `${Number(d.depth) || 0}%`;
|
|
37
|
+
case "reaction": return String(d.emoji ?? d.expression ?? "reaction").slice(0, 40);
|
|
38
|
+
case "task": return `${e.text ?? "task"}${typeof d.status === "string" ? ` (${d.status})` : ""}`;
|
|
39
|
+
case "say": case "custom": return e.text ?? (typeof d.command === "string" ? d.command : e.kind);
|
|
40
|
+
default: return label(e) || e.path || e.kind;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// FNV-1a: a stable id from the events, so the same input always builds the same package.
|
|
44
|
+
function hash(text) {
|
|
45
|
+
let h = 0x811c9dc5;
|
|
46
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
|
|
47
|
+
return (h >>> 0).toString(36);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* interface-event.v1[] → intent.v1. Invalid events are skipped; the rest are redacted again and read in time order.
|
|
52
|
+
* `evidence` holds indexes into the array you passed. `quality.billable` is false (zero-rated) when the session is under
|
|
53
|
+
* 60s, has fewer than 5 meaningful events, or holds no task, signals or friction.
|
|
54
|
+
*/
|
|
55
|
+
export function buildIntent(input, { task, id, session, now = Date.now() } = {}) {
|
|
56
|
+
const events = (Array.isArray(input) ? input : [])
|
|
57
|
+
.map((e, i) => (eventErrors(e).length ? null : { ...redactEvent(e), i }))
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.sort((a, b) => a.t - b.t || a.i - b.i);
|
|
60
|
+
|
|
61
|
+
// Aliases in first-appearance order: an agent sees "Speaker A", never a name. No `by` (solo capture) is one person.
|
|
62
|
+
const aliases = new Map();
|
|
63
|
+
const who = (e) => {
|
|
64
|
+
const key = e.by ?? "";
|
|
65
|
+
if (!aliases.has(key)) aliases.set(key, { alias: aliasLabel(aliases.size), events: 0 });
|
|
66
|
+
return aliases.get(key);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const timeline = [], signals = [], friction = [], open = new Map();
|
|
70
|
+
const rub = (kind, at, text, i, quote) => {
|
|
71
|
+
const key = `${kind}|${text}`, last = open.get(key);
|
|
72
|
+
if (last && at - last.seen <= MERGE_MS) {
|
|
73
|
+
last.seen = at;
|
|
74
|
+
if (last.item.evidence.length < 50) last.item.evidence.push(i);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const item = { at, kind, text, evidence: [i], ...(quote ? { quote } : {}) };
|
|
78
|
+
friction.push(item);
|
|
79
|
+
open.set(key, { item, seen: at });
|
|
80
|
+
};
|
|
81
|
+
const goalFromEvents = events.find((e) => e.kind === "task" && e.text)?.text;
|
|
82
|
+
const goal = (typeof task === "string" && task.trim().slice(0, 500)) || goalFromEvents;
|
|
83
|
+
let ended = null; // "done" | "abandon": the last explicit task status
|
|
84
|
+
|
|
85
|
+
for (const e of events) {
|
|
86
|
+
const p = who(e), by = p.alias, text = describe(e), d = e.data ?? {};
|
|
87
|
+
p.events++;
|
|
88
|
+
timeline.push({ at: e.t, by, kind: e.kind, text });
|
|
89
|
+
if (e.kind === "say" && e.text) {
|
|
90
|
+
const kind = d.feedback === true ? "feedback" : classify(e.text);
|
|
91
|
+
if (kind) signals.push({ kind, by, text: e.text, at: e.t });
|
|
92
|
+
if (kind === "friction") rub("said", e.t, "Said something isn't working", e.i, quoteOf(e.text));
|
|
93
|
+
} else if (e.kind === "custom" && typeof d.command === "string") {
|
|
94
|
+
signals.push({ kind: "command", by, text, at: e.t });
|
|
95
|
+
} else if (e.kind === "rage_click") rub("rage_click", e.t, `Clicked "${label(e) || "something"}" again and again`, e.i);
|
|
96
|
+
else if (e.kind === "dead_click") rub("dead_click", e.t, `Clicked "${label(e) || "something"}" and nothing happened`, e.i);
|
|
97
|
+
else if (e.kind === "error") rub("error", e.t, `Error: ${text}`, e.i);
|
|
98
|
+
else if (e.kind === "task" && typeof d.status === "string") {
|
|
99
|
+
if (DONE.test(d.status)) ended = "done";
|
|
100
|
+
else if (GAVE_UP.test(d.status)) {
|
|
101
|
+
ended = "abandon";
|
|
102
|
+
rub("abandoned", e.t, `Gave up on: ${e.text ?? goal ?? "the task"}`, e.i);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const meaningful = events.filter((e) => e.kind !== "scroll_depth");
|
|
108
|
+
let outcome = "unknown";
|
|
109
|
+
if (ended === "done") outcome = "achieved";
|
|
110
|
+
else if (ended === "abandon") outcome = "abandoned";
|
|
111
|
+
else if (goal && meaningful.length) {
|
|
112
|
+
// Unfinished: friction among the last three things they did, with no submit after it, reads as giving up.
|
|
113
|
+
const hurt = meaningful.findLastIndex((e) => FRICTION_EVENTS.has(e.kind) || (e.kind === "say" && e.text && classify(e.text) === "friction"));
|
|
114
|
+
if (hurt >= 0 && hurt >= meaningful.length - 3 && !meaningful.slice(hurt + 1).some((e) => e.kind === "submit")) {
|
|
115
|
+
outcome = "abandoned";
|
|
116
|
+
const last = meaningful.at(-1);
|
|
117
|
+
rub("abandoned", last.t, `Left without finishing: ${goal}`, last.i);
|
|
118
|
+
} else if (meaningful.some((e) => e.kind === "submit")) outcome = "partial";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const span = events.length ? events.at(-1).t - events[0].t : 0;
|
|
122
|
+
const found = (goal ? 1 : 0) + signals.length + friction.length;
|
|
123
|
+
const reasons = [
|
|
124
|
+
...(span < 60_000 ? ["Session shorter than 60s"] : []),
|
|
125
|
+
...(meaningful.length < 5 ? ["Fewer than 5 meaningful events"] : []),
|
|
126
|
+
...(found ? [] : ["No task, signals or friction found"]),
|
|
127
|
+
];
|
|
128
|
+
const score = Math.round((0.3 * Math.min(1, span / 300_000) + 0.3 * Math.min(1, meaningful.length / 25) + 0.4 * Math.min(1, found / 5)) * 100) / 100;
|
|
129
|
+
const sessionId = session ?? events[0]?.session ?? null;
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
schema: "intent.v1",
|
|
133
|
+
id: id ?? `ipk_${hash(JSON.stringify([sessionId, events.map(({ i: _i, ...e }) => e)]))}`,
|
|
134
|
+
session: sessionId,
|
|
135
|
+
...(goal ? { task: goal } : {}),
|
|
136
|
+
generatedAt: now,
|
|
137
|
+
outcome,
|
|
138
|
+
...(goal ? { goal } : {}),
|
|
139
|
+
friction: friction.slice(0, CAP.friction),
|
|
140
|
+
signals: signals.slice(0, CAP.signals),
|
|
141
|
+
timeline: timeline.slice(-CAP.timeline),
|
|
142
|
+
participants: [...aliases.values()],
|
|
143
|
+
quality: { score, billable: reasons.length === 0, reasons },
|
|
144
|
+
confidence: score >= 0.6 ? "medium: heuristics only" : "low: heuristics only",
|
|
145
|
+
};
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "inverse-interface",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Open runtime for Inverse Interface: a privacy-first semantic recorder, consent prompt, intent builder and client for the hosted API. Runs anywhere, free.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": { "types": "./index.d.ts", "default": "./index.js" },
|
|
8
|
+
"./recorder": { "types": "./recorder.d.ts", "default": "./recorder.js" },
|
|
9
|
+
"./consent": { "types": "./consent.d.ts", "default": "./consent.js" },
|
|
10
|
+
"./client": { "types": "./client.d.ts", "default": "./client.js" },
|
|
11
|
+
"./intent": { "types": "./intent.d.ts", "default": "./intent.js" },
|
|
12
|
+
"./schema": { "types": "./schema.d.ts", "default": "./schema.js" }
|
|
13
|
+
},
|
|
14
|
+
"files": ["index.js", "index.d.ts", "recorder.js", "recorder.d.ts", "consent.js", "consent.d.ts", "client.js", "client.d.ts", "intent.js", "intent.d.ts", "schema.js", "schema.d.ts", "browser.js", "README.md", "LICENSE"],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"homepage": "https://www.inverse.at/docs/interface",
|
|
18
|
+
"keywords": ["inverse", "session", "intent", "consent", "agents", "analytics"]
|
|
19
|
+
}
|
package/recorder.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type InterfaceTarget = { label?: string; role?: string; selector?: string };
|
|
2
|
+
export type RecordedEvent = { v: 1; t: number; kind: string; path: string; target?: InterfaceTarget; data?: Record<string, unknown> };
|
|
3
|
+
export const RAGE_CLICKS: number, RAGE_MS: number, DEAD_MS: number, SCROLL_MILESTONES: number[];
|
|
4
|
+
export function sensitive(el: Element): boolean;
|
|
5
|
+
export function redacted(el: Element): boolean;
|
|
6
|
+
export function role(el: Element): string | undefined;
|
|
7
|
+
export function label(el: Element, doc?: Document): string | undefined;
|
|
8
|
+
export function selector(el: Element): string;
|
|
9
|
+
export function target(el: Element): InterfaceTarget;
|
|
10
|
+
export function rageDetector(count?: number, ms?: number): (key: string, t: number) => boolean;
|
|
11
|
+
export function isDead(clickAt: number, lastChangeAt: number, now: number): boolean;
|
|
12
|
+
export function milestones(percent: number, seen: Set<number>): number[];
|
|
13
|
+
export type Recorder = { start(): Recorder; stop(): void; pause(on?: boolean): void };
|
|
14
|
+
export function createRecorder(options: { emit: (event: RecordedEvent) => void; win?: Window }): Recorder;
|
package/recorder.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* inverse-interface/recorder: semantic DOM events (interface-event.v1) without the content.
|
|
3
|
+
* Redaction happens here, at the source: it never reads `.value`, never reads text from fields or editable
|
|
4
|
+
* content, and skips password fields, payment fields (autocomplete="cc-*") and [data-inverse-redact] subtrees.
|
|
5
|
+
* Only the path is recorded, never the query string or hash (they can hold tokens).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const RAGE_CLICKS = 3, RAGE_MS = 600, DEAD_MS = 1000, SCROLL_MILESTONES = [25, 50, 75, 100];
|
|
9
|
+
const LABEL_MAX = 80, MESSAGE_MAX = 200;
|
|
10
|
+
|
|
11
|
+
/** A node that must never be recorded: a password or payment field, or marked with data-inverse-redact. */
|
|
12
|
+
export function sensitive(el) {
|
|
13
|
+
const attr = (n) => el.getAttribute?.(n);
|
|
14
|
+
return attr("data-inverse-redact") != null || (el.tagName === "INPUT" && attr("type")?.toLowerCase() === "password") || /^cc-/i.test(attr("autocomplete") ?? "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True when the node or any ancestor is sensitive. */
|
|
18
|
+
export function redacted(el) {
|
|
19
|
+
for (let n = el; n; n = n.parentElement) if (sensitive(n)) return true;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ROLES = { A: "link", BUTTON: "button", SELECT: "combobox", TEXTAREA: "textbox", SUMMARY: "button", FORM: "form", OPTION: "option" };
|
|
24
|
+
const INPUT_ROLES = { checkbox: "checkbox", radio: "radio", range: "slider", submit: "button", button: "button", reset: "button", search: "searchbox" };
|
|
25
|
+
export const role = (el) => el.getAttribute?.("role") || (el.tagName === "INPUT" ? INPUT_ROLES[el.getAttribute("type")?.toLowerCase()] ?? "textbox" : ROLES[el.tagName]) || undefined;
|
|
26
|
+
|
|
27
|
+
/** Text is only taken from elements whose text is a label (links, buttons, tabs), and never when they hold a field. */
|
|
28
|
+
const TEXT_ROLES = new Set(["link", "button", "tab", "menuitem", "option", "checkbox", "radio", "switch"]);
|
|
29
|
+
const clip = (s, n = LABEL_MAX) => (s = s?.replace(/\s+/g, " ").trim()) ? s.slice(0, n) : undefined;
|
|
30
|
+
|
|
31
|
+
/** The accessible label: aria-label, aria-labelledby, a <label for>, title/alt, then text for label-like roles. Never a value. */
|
|
32
|
+
export function label(el, doc = el.ownerDocument) {
|
|
33
|
+
const attr = (n) => el.getAttribute?.(n);
|
|
34
|
+
const by = attr("aria-labelledby")?.split(/\s+/).map((id) => doc?.getElementById(id)).filter((n) => n && !redacted(n));
|
|
35
|
+
const forLabel = el.id && doc?.querySelector?.(`label[for="${globalThis.CSS?.escape?.(el.id) ?? el.id}"]`);
|
|
36
|
+
return clip(attr("aria-label")) ?? clip(by?.map((n) => n.textContent).join(" ")) ?? (forLabel && !forLabel.querySelector("input,textarea,select,[contenteditable]") ? clip(forLabel.textContent) : undefined) ?? clip(attr("title") ?? attr("alt")) ??
|
|
37
|
+
(TEXT_ROLES.has(role(el)) && el.tagName !== "INPUT" && !el.querySelector?.("input,textarea,select,[contenteditable]") ? clip(el.textContent) : undefined);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A short selector: tag#id, tag[data-testid], or tag.first-class. */
|
|
41
|
+
export function selector(el) {
|
|
42
|
+
const tag = el.tagName.toLowerCase(), id = el.getAttribute("id"), test = el.getAttribute("data-testid"), cls = el.getAttribute("class")?.trim().split(/\s+/)[0];
|
|
43
|
+
return (id ? `${tag}#${id}` : test ? `${tag}[data-testid="${test}"]` : cls ? `${tag}.${cls}` : tag).slice(0, 100);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const target = (el) => {
|
|
47
|
+
const t = { label: label(el), role: role(el), selector: selector(el) };
|
|
48
|
+
for (const k in t) if (t[k] === undefined) delete t[k];
|
|
49
|
+
return t;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Rage clicks: `click(key, t)` is true once per burst, when the same target gets RAGE_CLICKS clicks within RAGE_MS. */
|
|
53
|
+
export function rageDetector(count = RAGE_CLICKS, ms = RAGE_MS) {
|
|
54
|
+
let key, times = [], fired = false;
|
|
55
|
+
return (k, t) => {
|
|
56
|
+
if (k !== key) (key = k), (times = []), (fired = false);
|
|
57
|
+
times = [...times.filter((x) => t - x < ms), t];
|
|
58
|
+
if (times.length < count) return (fired = false);
|
|
59
|
+
return fired ? false : (fired = true);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A dead click: nothing in the page changed (DOM or path) between the click and DEAD_MS after it. */
|
|
64
|
+
export const isDead = (clickAt, lastChangeAt, now) => lastChangeAt < clickAt && now - clickAt >= DEAD_MS;
|
|
65
|
+
|
|
66
|
+
/** Scroll milestones newly reached at `percent`, given the ones already seen (mutated). */
|
|
67
|
+
export function milestones(percent, seen) {
|
|
68
|
+
const hit = SCROLL_MILESTONES.filter((m) => percent >= m && !seen.has(m));
|
|
69
|
+
hit.forEach((m) => seen.add(m));
|
|
70
|
+
return hit;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEAD_ROLES = new Set(["link", "button", "tab", "menuitem"]);
|
|
74
|
+
const CLICKABLE = "a,button,input,select,textarea,summary,label,[role],[onclick],[tabindex]";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* createRecorder({ emit }) → { start, stop, pause(on = true) }.
|
|
78
|
+
* `emit(event)` gets `{ v: 1, t, kind, path, target?, data? }`; the client adds `session`.
|
|
79
|
+
*/
|
|
80
|
+
export function createRecorder({ emit, win = globalThis.window }) {
|
|
81
|
+
const doc = win.document;
|
|
82
|
+
let paused = false, path = null, lastChange = 0, seen = new Set(), off = [];
|
|
83
|
+
const rage = rageDetector();
|
|
84
|
+
const send = (kind, extra) => !paused && emit({ v: 1, t: Date.now(), kind, path: win.location.pathname, ...extra });
|
|
85
|
+
const listen = (on, type, fn, opts) => (on.addEventListener(type, fn, opts), off.push(() => on.removeEventListener(type, fn, opts)));
|
|
86
|
+
const navigate = () => {
|
|
87
|
+
if (win.location.pathname === path) return;
|
|
88
|
+
path = win.location.pathname;
|
|
89
|
+
lastChange = Date.now();
|
|
90
|
+
seen = new Set();
|
|
91
|
+
send("navigate");
|
|
92
|
+
};
|
|
93
|
+
const onClick = (e) => {
|
|
94
|
+
const raw = e.target?.nodeType === 1 ? e.target : e.target?.parentElement;
|
|
95
|
+
if (!raw || redacted(raw)) return;
|
|
96
|
+
const hit = raw.closest(CLICKABLE), t = target(hit ?? raw), at = Date.now();
|
|
97
|
+
send("click", { target: t });
|
|
98
|
+
if (rage(t.selector + (t.label ?? ""), at)) send("rage_click", { target: t });
|
|
99
|
+
// Dead clicks only on links and buttons: a click on text or into a field changing nothing is normal.
|
|
100
|
+
if (hit && DEAD_ROLES.has(t.role)) setTimeout(() => isDead(at, lastChange, Date.now()) && send("dead_click", { target: t }), DEAD_MS);
|
|
101
|
+
};
|
|
102
|
+
const onSubmit = (e) => e.target && !redacted(e.target) && send("submit", { target: target(e.target) });
|
|
103
|
+
const onError = (e) => send("error", { data: { message: clip(e.message || "Script error", MESSAGE_MAX) } });
|
|
104
|
+
const onRejection = (e) => send("error", { data: { message: clip(String(e.reason?.message ?? e.reason), MESSAGE_MAX), unhandled: true } });
|
|
105
|
+
let queued = false;
|
|
106
|
+
const onScroll = () => {
|
|
107
|
+
if (queued) return;
|
|
108
|
+
queued = true;
|
|
109
|
+
win.requestAnimationFrame(() => {
|
|
110
|
+
queued = false;
|
|
111
|
+
const h = doc.documentElement.scrollHeight;
|
|
112
|
+
for (const m of milestones(h ? Math.round(((win.scrollY + win.innerHeight) / h) * 100) : 100, seen)) send("scroll_depth", { data: { percent: m } });
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
start() {
|
|
117
|
+
if (off.length) return this;
|
|
118
|
+
const observer = new win.MutationObserver(() => (lastChange = Date.now()));
|
|
119
|
+
observer.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
120
|
+
off.push(() => observer.disconnect());
|
|
121
|
+
for (const method of ["pushState", "replaceState"]) {
|
|
122
|
+
const original = win.history[method];
|
|
123
|
+
win.history[method] = function (...args) {
|
|
124
|
+
const result = original.apply(this, args);
|
|
125
|
+
navigate();
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
128
|
+
off.push(() => (win.history[method] = original));
|
|
129
|
+
}
|
|
130
|
+
listen(win, "popstate", navigate);
|
|
131
|
+
listen(doc, "click", onClick, true);
|
|
132
|
+
listen(doc, "submit", onSubmit, true);
|
|
133
|
+
listen(win, "error", onError);
|
|
134
|
+
listen(win, "unhandledrejection", onRejection);
|
|
135
|
+
listen(win, "scroll", onScroll, { passive: true });
|
|
136
|
+
navigate();
|
|
137
|
+
return this;
|
|
138
|
+
},
|
|
139
|
+
stop() {
|
|
140
|
+
off.splice(0).forEach((fn) => fn());
|
|
141
|
+
path = null;
|
|
142
|
+
},
|
|
143
|
+
pause(on = true) {
|
|
144
|
+
paused = on;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
package/schema.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type EventKind = "navigate" | "click" | "input" | "submit" | "error" | "rage_click" | "dead_click" | "scroll_depth" | "say" | "reaction" | "task" | "custom";
|
|
2
|
+
/** interface-event.v1: one thing a person did. Never carries a value typed into an input. */
|
|
3
|
+
export type InterfaceEvent = {
|
|
4
|
+
v: 1;
|
|
5
|
+
/** ms since epoch */
|
|
6
|
+
t: number;
|
|
7
|
+
session: string;
|
|
8
|
+
kind: EventKind;
|
|
9
|
+
target?: { label?: string; role?: string; selector?: string };
|
|
10
|
+
/** Pathname only; query and hash are dropped. */
|
|
11
|
+
path?: string;
|
|
12
|
+
/** ≤500 chars, only on say, task and custom. */
|
|
13
|
+
text?: string;
|
|
14
|
+
/** ≤2KB; value-like keys are dropped. */
|
|
15
|
+
data?: Record<string, unknown>;
|
|
16
|
+
/** An alias, never a name. */
|
|
17
|
+
by?: string;
|
|
18
|
+
};
|
|
19
|
+
export type Outcome = "achieved" | "partial" | "abandoned" | "unknown";
|
|
20
|
+
export type SignalKind = "question" | "friction" | "praise" | "request" | "command" | "feedback";
|
|
21
|
+
export type Friction = { at: number; kind: string; text: string; evidence: number[]; quote?: string };
|
|
22
|
+
/** intent.v1: what happened in a session, shaped for an agent. Every `by` and `alias` is "Speaker A"…, never a name. */
|
|
23
|
+
export type Intent = {
|
|
24
|
+
schema: "intent.v1";
|
|
25
|
+
id: string;
|
|
26
|
+
session: string | null;
|
|
27
|
+
task?: string;
|
|
28
|
+
generatedAt: number;
|
|
29
|
+
outcome: Outcome;
|
|
30
|
+
goal?: string;
|
|
31
|
+
friction: Friction[];
|
|
32
|
+
signals: { kind: SignalKind; by: string; text: string; at: number }[];
|
|
33
|
+
timeline: { at: number; by: string; kind: string; text: string }[];
|
|
34
|
+
participants: { alias: string; events: number }[];
|
|
35
|
+
quality: { score: number; billable: boolean; reasons: string[] };
|
|
36
|
+
confidence: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export declare const EVENT_KINDS: EventKind[];
|
|
40
|
+
export declare const TEXT_KINDS: EventKind[];
|
|
41
|
+
export declare const OUTCOMES: Outcome[];
|
|
42
|
+
export declare const SIGNAL_KINDS: SignalKind[];
|
|
43
|
+
export declare const LIMITS: { text: number; data: number; label: number; role: number; selector: number; path: number; by: number; session: number };
|
|
44
|
+
/** `[]` when the event is well-formed interface-event.v1, else what's wrong. */
|
|
45
|
+
export declare function eventErrors(event: unknown): string[];
|
|
46
|
+
/** The event as it may be stored: known fields only, capped, query/hash and value-like data dropped. Call after `eventErrors` passes. */
|
|
47
|
+
export declare function redactEvent(event: InterfaceEvent): InterfaceEvent;
|
|
48
|
+
/** `[]` when the package is well-formed intent.v1. */
|
|
49
|
+
export declare function intentErrors(pkg: unknown): string[];
|
|
50
|
+
export declare const eventJsonSchema: Record<string, unknown>;
|
|
51
|
+
export declare const intentJsonSchema: Record<string, unknown>;
|
package/schema.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The open formats: `interface-event.v1` (what a person did) and `intent.v1` (what an agent gets).
|
|
2
|
+
// Plain validators with no dependencies, so the recorder, the Inverse API and anyone else check the same rules.
|
|
3
|
+
|
|
4
|
+
export const EVENT_KINDS = ["navigate", "click", "input", "submit", "error", "rage_click", "dead_click", "scroll_depth", "say", "reaction", "task", "custom"];
|
|
5
|
+
export const TEXT_KINDS = ["say", "task", "custom"]; // the only kinds that carry free text
|
|
6
|
+
export const OUTCOMES = ["achieved", "partial", "abandoned", "unknown"];
|
|
7
|
+
export const SIGNAL_KINDS = ["question", "friction", "praise", "request", "command", "feedback"];
|
|
8
|
+
export const LIMITS = { text: 500, data: 2048, label: 120, role: 40, selector: 200, path: 2000, by: 40, session: 64 };
|
|
9
|
+
|
|
10
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
11
|
+
const str = (v, max) => (typeof v === "string" ? v.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").slice(0, max) : undefined);
|
|
12
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v);
|
|
13
|
+
|
|
14
|
+
// Keys that could carry what someone typed, a secret or a contact detail. Dropped wherever they appear in `data`.
|
|
15
|
+
const VALUE_KEY = /value|password|passwd|secret|token|card|cc-|cvc|cvv|ssn|email|phone|iban/i;
|
|
16
|
+
function scrub(v, depth = 0) {
|
|
17
|
+
if (depth > 4) return undefined;
|
|
18
|
+
if (Array.isArray(v)) return v.slice(0, 50).map((x) => scrub(x, depth + 1)).filter((x) => x !== undefined);
|
|
19
|
+
if (isObj(v)) {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const [k, x] of Object.entries(v)) {
|
|
22
|
+
if (VALUE_KEY.test(k)) continue;
|
|
23
|
+
const s = scrub(x, depth + 1);
|
|
24
|
+
if (s !== undefined) out[k.slice(0, 64)] = s;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
if (typeof v === "string") return str(v, LIMITS.text);
|
|
29
|
+
return typeof v === "number" || typeof v === "boolean" || v === null ? v : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `[]` when the event is a well-formed interface-event.v1, else what's wrong. Extra or oversize fields are `redactEvent`'s job. */
|
|
33
|
+
export function eventErrors(e) {
|
|
34
|
+
if (!isObj(e)) return ["event must be an object"];
|
|
35
|
+
const errors = [];
|
|
36
|
+
if (e.v !== 1) errors.push("v must be 1");
|
|
37
|
+
if (!num(e.t)) errors.push("t must be a number (ms)");
|
|
38
|
+
if (typeof e.session !== "string" || !e.session || e.session.length > LIMITS.session) errors.push("session must be a string of 1-64 chars");
|
|
39
|
+
if (!EVENT_KINDS.includes(e.kind)) errors.push(`kind must be one of ${EVENT_KINDS.join(", ")}`);
|
|
40
|
+
if (e.target !== undefined && !isObj(e.target)) errors.push("target must be an object");
|
|
41
|
+
if (e.data !== undefined && !isObj(e.data)) errors.push("data must be an object");
|
|
42
|
+
for (const k of ["path", "text", "by"]) if (e[k] !== undefined && typeof e[k] !== "string") errors.push(`${k} must be a string`);
|
|
43
|
+
return errors;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The event as it may be stored: only known fields, capped, and never a value someone typed. `text` survives only on
|
|
48
|
+
* say/task/custom; a path loses its query and hash (they often carry emails and tokens); `data` loses value-like keys
|
|
49
|
+
* and is dropped above 2KB. Run it on both sides (recorder and server): redaction is defense in depth.
|
|
50
|
+
*/
|
|
51
|
+
export function redactEvent(e) {
|
|
52
|
+
const out = { v: 1, t: e.t, session: String(e.session).slice(0, LIMITS.session), kind: e.kind };
|
|
53
|
+
if (isObj(e.target)) {
|
|
54
|
+
const target = {};
|
|
55
|
+
for (const k of ["label", "role", "selector"]) {
|
|
56
|
+
const s = str(e.target[k], LIMITS[k]);
|
|
57
|
+
if (s) target[k] = s;
|
|
58
|
+
}
|
|
59
|
+
if (Object.keys(target).length) out.target = target;
|
|
60
|
+
}
|
|
61
|
+
const path = str(e.path, LIMITS.path)?.split(/[?#]/)[0];
|
|
62
|
+
if (path) out.path = path;
|
|
63
|
+
const text = TEXT_KINDS.includes(e.kind) ? str(e.text, LIMITS.text) : undefined;
|
|
64
|
+
if (text) out.text = text;
|
|
65
|
+
if (isObj(e.data)) {
|
|
66
|
+
const data = scrub(e.data);
|
|
67
|
+
if (Object.keys(data).length && JSON.stringify(data).length <= LIMITS.data) out.data = data;
|
|
68
|
+
}
|
|
69
|
+
const by = str(e.by, LIMITS.by);
|
|
70
|
+
if (by) out.by = by;
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `[]` when `pkg` is a well-formed intent.v1. */
|
|
75
|
+
export function intentErrors(pkg) {
|
|
76
|
+
if (!isObj(pkg)) return ["intent must be an object"];
|
|
77
|
+
const errors = [];
|
|
78
|
+
const need = (ok, message) => ok || errors.push(message);
|
|
79
|
+
need(pkg.schema === "intent.v1", 'schema must be "intent.v1"');
|
|
80
|
+
need(typeof pkg.id === "string" && pkg.id.startsWith("ipk_"), "id must start with ipk_");
|
|
81
|
+
need(pkg.session === null || typeof pkg.session === "string", "session must be a string or null");
|
|
82
|
+
need(num(pkg.generatedAt), "generatedAt must be a number");
|
|
83
|
+
need(OUTCOMES.includes(pkg.outcome), `outcome must be one of ${OUTCOMES.join(", ")}`);
|
|
84
|
+
need(Array.isArray(pkg.friction) && pkg.friction.every((f) => isObj(f) && num(f.at) && typeof f.kind === "string" && typeof f.text === "string" && Array.isArray(f.evidence)), "friction must be [{ at, kind, text, evidence }]");
|
|
85
|
+
need(Array.isArray(pkg.signals) && pkg.signals.every((s) => isObj(s) && SIGNAL_KINDS.includes(s.kind) && typeof s.text === "string" && num(s.at)), "signals must be [{ kind, by, text, at }]");
|
|
86
|
+
need(Array.isArray(pkg.timeline), "timeline must be an array");
|
|
87
|
+
need(Array.isArray(pkg.participants), "participants must be an array");
|
|
88
|
+
const q = pkg.quality;
|
|
89
|
+
need(isObj(q) && num(q.score) && q.score >= 0 && q.score <= 1 && typeof q.billable === "boolean" && Array.isArray(q.reasons), "quality must be { score 0..1, billable, reasons }");
|
|
90
|
+
need(typeof pkg.confidence === "string", "confidence must be a string");
|
|
91
|
+
return errors;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const S = (max) => ({ type: "string", maxLength: max });
|
|
95
|
+
/** JSON Schema (2020-12) for interface-event.v1. */
|
|
96
|
+
export const eventJsonSchema = {
|
|
97
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
98
|
+
$id: "https://www.inverse.at/schemas/interface-event.v1.json",
|
|
99
|
+
title: "interface-event.v1",
|
|
100
|
+
type: "object",
|
|
101
|
+
required: ["v", "t", "session", "kind"],
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
properties: {
|
|
104
|
+
v: { const: 1 },
|
|
105
|
+
t: { type: "number", description: "ms since epoch" },
|
|
106
|
+
session: { type: "string", minLength: 1, maxLength: LIMITS.session },
|
|
107
|
+
kind: { enum: EVENT_KINDS },
|
|
108
|
+
target: { type: "object", additionalProperties: false, properties: { label: S(LIMITS.label), role: S(LIMITS.role), selector: S(LIMITS.selector) }, description: "Never a value from an input" },
|
|
109
|
+
path: { ...S(LIMITS.path), description: "Pathname only; query and hash are dropped" },
|
|
110
|
+
text: { ...S(LIMITS.text), description: "Only on say, task and custom" },
|
|
111
|
+
data: { type: "object", description: "≤2KB; value-like keys are dropped" },
|
|
112
|
+
by: { ...S(LIMITS.by), description: "An alias, never a name" },
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
/** JSON Schema (2020-12) for intent.v1. */
|
|
116
|
+
export const intentJsonSchema = {
|
|
117
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
118
|
+
$id: "https://www.inverse.at/schemas/intent.v1.json",
|
|
119
|
+
title: "intent.v1",
|
|
120
|
+
type: "object",
|
|
121
|
+
required: ["schema", "id", "session", "generatedAt", "outcome", "friction", "signals", "timeline", "participants", "quality", "confidence"],
|
|
122
|
+
properties: {
|
|
123
|
+
schema: { const: "intent.v1" },
|
|
124
|
+
id: { type: "string", pattern: "^ipk_" },
|
|
125
|
+
session: { type: ["string", "null"] },
|
|
126
|
+
task: { type: "string" },
|
|
127
|
+
goal: { type: "string" },
|
|
128
|
+
generatedAt: { type: "number" },
|
|
129
|
+
outcome: { enum: OUTCOMES },
|
|
130
|
+
friction: { type: "array", items: { type: "object", required: ["at", "kind", "text", "evidence"], properties: { at: { type: "number" }, kind: { type: "string" }, text: { type: "string" }, evidence: { type: "array", items: { type: "integer" } }, quote: { type: "string" } } } },
|
|
131
|
+
signals: { type: "array", items: { type: "object", required: ["kind", "by", "text", "at"], properties: { kind: { enum: SIGNAL_KINDS }, by: { type: "string" }, text: { type: "string" }, at: { type: "number" } } } },
|
|
132
|
+
timeline: { type: "array", items: { type: "object", required: ["at", "by", "kind", "text"], properties: { at: { type: "number" }, by: { type: "string" }, kind: { type: "string" }, text: { type: "string" } } } },
|
|
133
|
+
participants: { type: "array", items: { type: "object", required: ["alias", "events"], properties: { alias: { type: "string" }, events: { type: "integer" } } } },
|
|
134
|
+
quality: { type: "object", required: ["score", "billable", "reasons"], properties: { score: { type: "number", minimum: 0, maximum: 1 }, billable: { type: "boolean" }, reasons: { type: "array", items: { type: "string" } } } },
|
|
135
|
+
confidence: { type: "string" },
|
|
136
|
+
},
|
|
137
|
+
};
|