@passiveintent/core 1.0.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 +661 -0
- package/README.md +711 -0
- package/dist/adapters.d.ts +183 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/calibration.cjs +2 -0
- package/dist/calibration.cjs.map +1 -0
- package/dist/calibration.d.ts +49 -0
- package/dist/calibration.d.ts.map +1 -0
- package/dist/calibration.js +2 -0
- package/dist/calibration.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/intent-sdk-performance.d.ts +71 -0
- package/dist/intent-sdk-performance.d.ts.map +1 -0
- package/dist/intent-sdk.d.ts +44 -0
- package/dist/intent-sdk.d.ts.map +1 -0
- package/dist/performance-instrumentation.d.ts +55 -0
- package/dist/performance-instrumentation.d.ts.map +1 -0
- package/dist/reporting-utils.d.ts +17 -0
- package/dist/reporting-utils.d.ts.map +1 -0
- package/package.json +99 -0
package/README.md
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Copyright (c) 2026 Purushottam <purushottam@passiveintent.dev>
|
|
3
|
+
|
|
4
|
+
This source code is licensed under the AGPL-3.0-only license found in the
|
|
5
|
+
LICENSE file in the root directory of this source tree.
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
# @passiveintent/core — PassiveIntent: A Privacy-First Intent Engine
|
|
9
|
+
|
|
10
|
+
[](#run-tests)
|
|
11
|
+
[](https://bundlephobia.com/package/@passiveintent/core)
|
|
12
|
+
[](https://www.npmjs.com/package/@passiveintent/core)
|
|
13
|
+
[](https://stackblitz.com/github/passiveintent/core/tree/main/demo)
|
|
14
|
+
[](https://stackblitz.com/github/passiveintent/core/tree/main/demo-react)
|
|
15
|
+
|
|
16
|
+
**PassiveIntent is a ~11 kB gzip, zero-egress intent engine that detects user hesitation and frustration in real-time.**
|
|
17
|
+
Catch rage-clicks, prevent checkout abandonment, and trigger personalized UI interventions in `< 2ms`—all entirely within the browser. Because zero behavioral data leaves the device by default, PassiveIntent can **reduce cookie-consent and GDPR overhead** for intent detection, subject to your full implementation and legal review.
|
|
18
|
+
|
|
19
|
+
_(Under the hood, it uses a highly-optimized sparse Markov graph and Bloom filters to model probabilistic intent locally.)_
|
|
20
|
+
|
|
21
|
+
## Why PassiveIntent?
|
|
22
|
+
|
|
23
|
+
- **No Cookie Banners Required:** 100% local execution. No network requests, no PII sent to servers. Designed to help you meet GDPR and CCPA requirements when used with appropriate configuration and legal review.
|
|
24
|
+
- **Sub-Millisecond Reactions:** Catch frustrated users _before_ they close the tab. Traditional analytics take minutes to process rage-clicks; PassiveIntent triggers in `< 2ms`.
|
|
25
|
+
- **Detect True Hesitation:** Evaluates user reading speed and dwell-time anomalies dynamically, allowing you to trigger "Free Shipping" tooltips exactly when a user hesitates at checkout.
|
|
26
|
+
- **Cold-Start Friendly Math:** Unlike brittle rule engines that overreact to brand-new users, PassiveIntent can apply Bayesian Laplace smoothing (`smoothingAlpha`) so Day-1 organic traffic is handled gracefully instead of being penalized by sparse-history spikes.
|
|
27
|
+
- **Bot & Scraper Resilient:** Built-in `EntropyGuard` automatically detects impossibly fast or robotic click cadences, preventing bots from triggering your interventions.
|
|
28
|
+
- **Zero Performance Hit:** Capped at 500 tracked states, compiles to a tiny ~11 kB gzip footprint, and uses dirty-flag persistence to skip unnecessary writes.
|
|
29
|
+
- **SPA-Ready Lifecycle:** SSR-safe adapters and a clean `destroy()` API make it drop-in compatible with Next.js, Vue, Angular, and React Router.
|
|
30
|
+
- **Comparison Shopper Awareness:** Automatically detects users who leave and return after ≥ 15 seconds, firing an `attention_return` event so you can greet them with a personalized welcome-back offer.
|
|
31
|
+
- **Idle-State Detection:** Tracks interaction silence with a lightweight polling loop and fires `user_idle` / `user_resumed` events, letting you dim overlays or pause expensive animations without any extra timers.
|
|
32
|
+
- **Smart Exit-Intent:** Detects when the user is about to leave the page (pointer moves above the viewport) and fires `exit_intent` — **only** when the Markov graph confirms a likely continuation path. No spammy overlays; only data-backed interventions.
|
|
33
|
+
|
|
34
|
+
## What can you build?
|
|
35
|
+
|
|
36
|
+
**1. The Zero-Latency Churn Healer**
|
|
37
|
+
|
|
38
|
+
Detect when a user is frustrated (erratic navigation, rage-clicking) and instantly offer help.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
intent.on('high_entropy', (signal) => {
|
|
42
|
+
if (signal.state === '/billing' && signal.normalizedEntropy > 0.85) {
|
|
43
|
+
ZendeskWidget.open({ message: 'Having trouble with your billing details? Chat with us!' });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**2. The Hesitation Discount (Intervention Ladder)**
|
|
49
|
+
|
|
50
|
+
Detect when a user stalls on a checkout step compared to their normal browsing speed.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
intent.on('dwell_time_anomaly', (signal) => {
|
|
54
|
+
if (signal.state === '/checkout/payment' && signal.zScore > 2.0) {
|
|
55
|
+
// User is hesitating. Show a reassurance tooltip.
|
|
56
|
+
UI.showTooltip('Free 30-day returns on all orders.');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**3. The Abandoned-Path Detector**
|
|
62
|
+
|
|
63
|
+
Learn what the normal conversion path looks like and fire an event the moment a user deviates.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
intent.on('trajectory_anomaly', (signal) => {
|
|
67
|
+
if (signal.zScore > 2.5) {
|
|
68
|
+
Analytics.track('checkout_path_abandoned', { zScore: signal.zScore });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**4. The Comparison Shopper — Welcome Back Discount**
|
|
74
|
+
|
|
75
|
+
Detect when a user tabs away (likely to compare prices) and show a "Welcome Back" offer instantly on return.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
intent.on('attention_return', ({ state, hiddenDuration }) => {
|
|
79
|
+
if (state === '/product' || state === '/pricing') {
|
|
80
|
+
UI.showModal({
|
|
81
|
+
title: 'Welcome back!',
|
|
82
|
+
message: `Still comparing? Here's 10% off for the next 15 minutes.`,
|
|
83
|
+
coupon: 'WELCOMEBACK10',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**5. The Idle-State Overlay**
|
|
90
|
+
|
|
91
|
+
Detect when a user walks away from their device and dim the UI; refresh stale content when they return.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
intent.on('user_idle', ({ state }) => {
|
|
95
|
+
UI.showIdleOverlay({ message: 'Still there? Your session is open.' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
intent.on('user_resumed', ({ state, idleMs }) => {
|
|
99
|
+
UI.hideIdleOverlay();
|
|
100
|
+
if (idleMs > 300_000) {
|
|
101
|
+
refreshPageData(); // content may be stale after 5+ min
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**6. The Smart Exit-Intent Interceptor**
|
|
107
|
+
|
|
108
|
+
Fire a last-chance offer or save-progress prompt only when the Markov graph suggests the user has a meaningful next destination — not on every accidental cursor drift to the toolbar.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
intent.on('exit_intent', ({ state, likelyNext }) => {
|
|
112
|
+
if (state === '/checkout/payment') {
|
|
113
|
+
// The graph says they're likely to navigate to /checkout/review next —
|
|
114
|
+
// show a quick win to keep them in the funnel.
|
|
115
|
+
UI.showModal({
|
|
116
|
+
title: 'Wait — your cart is saved!',
|
|
117
|
+
message: `You were heading to ${likelyNext}. Need help completing your order?`,
|
|
118
|
+
cta: 'Continue checkout',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Install
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm install @passiveintent/core
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Quick start
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { IntentManager, BrowserStorageAdapter, BrowserTimerAdapter } from '@passiveintent/core';
|
|
134
|
+
|
|
135
|
+
// 1. Initialize the engine
|
|
136
|
+
const intent = new IntentManager({
|
|
137
|
+
storageKey: 'my-app-intent',
|
|
138
|
+
storage: new BrowserStorageAdapter(),
|
|
139
|
+
timer: new BrowserTimerAdapter(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// 2. Track page views or UI states
|
|
143
|
+
intent.track('/home');
|
|
144
|
+
intent.track('/pricing');
|
|
145
|
+
intent.track('/checkout');
|
|
146
|
+
|
|
147
|
+
// 3. Listen for behavioral signals
|
|
148
|
+
intent.on('dwell_time_anomaly', (signal) => {
|
|
149
|
+
// User is hesitating — offer help
|
|
150
|
+
console.log('Hesitation detected on', signal.state, '— z-score:', signal.zScore);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
intent.on('trajectory_anomaly', (signal) => {
|
|
154
|
+
// User deviated heavily from the normal conversion path
|
|
155
|
+
console.log('Path deviation detected. Z-Score:', signal.zScore);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
intent.on('high_entropy', (signal) => {
|
|
159
|
+
// User is bouncing around erratically — possible frustration
|
|
160
|
+
console.log('Erratic navigation on', signal.state, signal.normalizedEntropy);
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
> **Advanced configuration** (baselines, tuning thresholds, cross-tab sync, `onError` callback) is covered in the [full API reference](#api-highlights) and [architecture docs](./docs/architecture.md).
|
|
165
|
+
|
|
166
|
+
## Framework integration
|
|
167
|
+
|
|
168
|
+
### Next.js (App Router — `app/` directory)
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
// app/providers/intent-provider.tsx
|
|
172
|
+
'use client';
|
|
173
|
+
|
|
174
|
+
import { useEffect, useRef } from 'react';
|
|
175
|
+
import { usePathname } from 'next/navigation';
|
|
176
|
+
import { IntentManager, BrowserStorageAdapter, BrowserTimerAdapter } from '@passiveintent/core';
|
|
177
|
+
|
|
178
|
+
export function IntentProvider({ children }: { children: React.ReactNode }) {
|
|
179
|
+
const pathname = usePathname();
|
|
180
|
+
const intentRef = useRef<IntentManager | null>(null);
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
intentRef.current = new IntentManager({
|
|
184
|
+
storageKey: 'passive-intent',
|
|
185
|
+
storage: new BrowserStorageAdapter(),
|
|
186
|
+
timer: new BrowserTimerAdapter(),
|
|
187
|
+
});
|
|
188
|
+
return () => {
|
|
189
|
+
intentRef.current?.destroy();
|
|
190
|
+
intentRef.current = null;
|
|
191
|
+
};
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
intentRef.current?.track(pathname);
|
|
196
|
+
}, [pathname]);
|
|
197
|
+
|
|
198
|
+
return <>{children}</>;
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Mount the provider in your root layout:
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
// app/layout.tsx
|
|
206
|
+
import { IntentProvider } from './providers/intent-provider';
|
|
207
|
+
|
|
208
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
209
|
+
return (
|
|
210
|
+
<html lang="en">
|
|
211
|
+
<body>
|
|
212
|
+
<IntentProvider>{children}</IntentProvider>
|
|
213
|
+
</body>
|
|
214
|
+
</html>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Vue 3 (`onMounted` / `onUnmounted`)
|
|
220
|
+
|
|
221
|
+
```vue
|
|
222
|
+
<!-- src/composables/useIntent.ts -->
|
|
223
|
+
<script setup lang="ts">
|
|
224
|
+
import { onMounted, onUnmounted, watch } from 'vue';
|
|
225
|
+
import { useRoute } from 'vue-router';
|
|
226
|
+
import { IntentManager, BrowserStorageAdapter, BrowserTimerAdapter } from '@passiveintent/core';
|
|
227
|
+
|
|
228
|
+
let intent: IntentManager | null = null;
|
|
229
|
+
const route = useRoute();
|
|
230
|
+
|
|
231
|
+
onMounted(() => {
|
|
232
|
+
intent = new IntentManager({
|
|
233
|
+
storageKey: 'passive-intent',
|
|
234
|
+
storage: new BrowserStorageAdapter(),
|
|
235
|
+
timer: new BrowserTimerAdapter(),
|
|
236
|
+
});
|
|
237
|
+
intent.track(route.fullPath);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
watch(
|
|
241
|
+
() => route.fullPath,
|
|
242
|
+
(path) => {
|
|
243
|
+
intent?.track(path);
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
onUnmounted(() => {
|
|
248
|
+
intent?.destroy();
|
|
249
|
+
intent = null;
|
|
250
|
+
});
|
|
251
|
+
</script>
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Angular (`ngOnInit` / `ngOnDestroy`)
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
// intent.service.ts
|
|
258
|
+
import { Injectable, OnDestroy } from '@angular/core';
|
|
259
|
+
import { Router, NavigationEnd } from '@angular/router';
|
|
260
|
+
import { filter, Subscription } from 'rxjs';
|
|
261
|
+
import { IntentManager, BrowserStorageAdapter, BrowserTimerAdapter } from '@passiveintent/core';
|
|
262
|
+
|
|
263
|
+
@Injectable({ providedIn: 'root' })
|
|
264
|
+
export class IntentService implements OnDestroy {
|
|
265
|
+
private intent = new IntentManager({
|
|
266
|
+
storageKey: 'passive-intent',
|
|
267
|
+
storage: new BrowserStorageAdapter(),
|
|
268
|
+
timer: new BrowserTimerAdapter(),
|
|
269
|
+
});
|
|
270
|
+
private sub: Subscription;
|
|
271
|
+
|
|
272
|
+
constructor(router: Router) {
|
|
273
|
+
this.sub = router.events
|
|
274
|
+
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
|
|
275
|
+
.subscribe((e) => this.intent.track(e.urlAfterRedirects));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
ngOnDestroy(): void {
|
|
279
|
+
this.sub.unsubscribe();
|
|
280
|
+
this.intent.destroy();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Inject `IntentService` in your root `AppComponent` (or import it in the root module) so it is instantiated on app start.
|
|
286
|
+
|
|
287
|
+
## API highlights
|
|
288
|
+
|
|
289
|
+
### BloomFilter
|
|
290
|
+
|
|
291
|
+
| Method / Property | Description |
|
|
292
|
+
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
293
|
+
| `BloomFilter.computeOptimal(expectedItems, targetFPR)` | Static factory: computes optimal `bitSize` and `hashCount` for given capacity and target false-positive rate. |
|
|
294
|
+
| `computeBloomConfig(expectedItems, targetFPR)` | **Standalone tree-shakeable utility** (exported separately from the class). Returns `{ bitSize, hashCount, estimatedFpRate }` — use when you don't need the class itself. |
|
|
295
|
+
| `add(item)` | O(k) insert — hashes item into bitset. |
|
|
296
|
+
| `check(item)` | O(k) membership test — returns `true` if item was probably added. |
|
|
297
|
+
| `estimateCurrentFPR(insertedItemsCount)` | Estimates live false-positive rate given how many items have been inserted. |
|
|
298
|
+
| `toBase64()` / `BloomFilter.fromBase64(str, k)` | Compact base64 serialization for snapshot storage. |
|
|
299
|
+
|
|
300
|
+
### MarkovGraph
|
|
301
|
+
|
|
302
|
+
| Method / Property | Description |
|
|
303
|
+
| -------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
304
|
+
| `incrementTransition(from, to)` | Record a from→to navigation; creates states on demand. |
|
|
305
|
+
| `getLikelyNextStates(state, threshold)` | Returns `{ state, probability }[]` sorted descending; entries below `threshold` excluded. |
|
|
306
|
+
| `prune()` | LFU-style eviction of the lowest-frequency states when `maxStates` is exceeded. |
|
|
307
|
+
| `stateCount()` | Current number of unique tracked states. |
|
|
308
|
+
| `totalTransitions()` | Total recorded transition count across all edges. |
|
|
309
|
+
| `toBinary()` / `MarkovGraph.fromBinary(buf)` | Compact binary persistence (smaller than JSON at scale). |
|
|
310
|
+
| `toJSON()` / `MarkovGraph.fromJSON(obj)` | Human-readable snapshot; use for baseline transport and tooling. |
|
|
311
|
+
|
|
312
|
+
### IntentManager
|
|
313
|
+
|
|
314
|
+
**Static factory**
|
|
315
|
+
|
|
316
|
+
| Method | Signature | Description |
|
|
317
|
+
| ----------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
318
|
+
| `IntentManager.createAsync(config)` | `(config: IntentManagerConfig) => Promise<IntentManager>` | Async factory for use with `asyncStorage` backends (e.g. React Native `AsyncStorage`, IndexedDB wrappers). Awaits the initial `getItem` before constructing the instance so the synchronous `track()` hot-path is never blocked. Throws if `config.asyncStorage` is absent. |
|
|
319
|
+
|
|
320
|
+
**Lifecycle & tracking**
|
|
321
|
+
|
|
322
|
+
| Method | Signature | Description |
|
|
323
|
+
| -------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
324
|
+
| `track` | `(state: string) => void` | Core call: updates Bloom + Markov + fires event signals. |
|
|
325
|
+
| `on` | `(event: IntentEventName, handler) => () => void` | Subscribe to an event; call the returned function to unsubscribe. |
|
|
326
|
+
| `flushNow` | `() => void` | Cancel the debounce timer and persist immediately. |
|
|
327
|
+
| `destroy` | `() => void` | Flush, cancel timers, remove all listeners, close BroadcastChannel. Call in SPA teardown. |
|
|
328
|
+
| `resetSession` | `() => void` | Clear recent trajectory and previous state while preserving the learned graph. |
|
|
329
|
+
|
|
330
|
+
**Prediction & introspection**
|
|
331
|
+
|
|
332
|
+
| Method | Signature | Description |
|
|
333
|
+
| ---------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
334
|
+
| `predictNextStates` | `(threshold?: number, sanitize?: (s: string) => boolean) => { state, probability }[]` | Top-N Markov predictions above `threshold` (default `0.3`). Always provide a `sanitize` guard in production to exclude sensitive routes. |
|
|
335
|
+
| `hasSeen` | `(state: string) => boolean` | Bloom filter membership test — O(k), no false negatives. |
|
|
336
|
+
| `getTelemetry` | `() => PassiveIntentTelemetry` | GDPR-safe aggregate snapshot: `sessionId`, `transitionsEvaluated`, `botStatus`, `anomaliesFired`, `engineHealth`, `baselineStatus`, `assignmentGroup`. No raw behavioral data. |
|
|
337
|
+
| `exportGraph` | `() => SerializedMarkovGraph` | Returns the full Markov graph as a JSON-serializable object. |
|
|
338
|
+
| `getPerformanceReport` | `() => PerformanceReport` | Detailed benchmark report: op latencies, state/transition counts, serialization size. |
|
|
339
|
+
|
|
340
|
+
**Session counters** (exact integer counts, never persisted)
|
|
341
|
+
|
|
342
|
+
| Method | Signature | Description |
|
|
343
|
+
| ------------------ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
344
|
+
| `incrementCounter` | `(key: string, by?: number) => number` | Increment a named counter (default `+1`); accepts any finite value (including negative deltas) and returns the new value. Synced cross-tab when `BroadcastSync` is active. |
|
|
345
|
+
| `getCounter` | `(key: string) => number` | Read a counter; returns `0` if never incremented. |
|
|
346
|
+
| `resetCounter` | `(key: string) => void` | Reset a counter back to `0`. |
|
|
347
|
+
|
|
348
|
+
**Conversion tracking**
|
|
349
|
+
|
|
350
|
+
| Method | Signature | Description |
|
|
351
|
+
| ----------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
352
|
+
| `trackConversion` | `(payload: ConversionPayload) => void` | Emit a `conversion` event locally. `ConversionPayload` carries `type`, optional `value`, optional `currency`. Never leaves the device unless your listener sends it. |
|
|
353
|
+
|
|
354
|
+
**Events emitted** (`on(event, handler)`)
|
|
355
|
+
|
|
356
|
+
| Event | Payload type | Fired when |
|
|
357
|
+
| --------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
358
|
+
| `state_change` | `StateChangePayload` | Every `track()` call that records a new transition. |
|
|
359
|
+
| `high_entropy` | `HighEntropyPayload` | Outgoing-transition distribution exceeds `highEntropyThreshold`. |
|
|
360
|
+
| `trajectory_anomaly` | `TrajectoryAnomalyPayload` | Log-likelihood window diverges from baseline beyond `divergenceThreshold`. |
|
|
361
|
+
| `dwell_time_anomaly` | `DwellTimeAnomalyPayload` | Time on previous state deviates beyond z-score threshold (Welford's algorithm). |
|
|
362
|
+
| `bot_detected` | `BotDetectedPayload` | `botScore` reaches 5 — EntropyGuard flags the session. |
|
|
363
|
+
| `hesitation_detected` | `HesitationDetectedPayload` | A `trajectory_anomaly` and positive `dwell_time_anomaly` occur within `hesitationCorrelationWindowMs`. |
|
|
364
|
+
| `session_stale` | `SessionStalePayload` | **Only emitted when `dwellTime.enabled` is `true`.** A time delta (hidden-duration from `LifecycleAdapter`, or dwell measured at `track()` time) exceeded `MAX_PLAUSIBLE_DWELL_MS` (30 min), indicating CPU suspend or OS sleep. The inflated measurement is discarded to protect the Welford accumulator. |
|
|
365
|
+
| `attention_return` | `AttentionReturnPayload` | User returns to the tab after being hidden for ≥ `ATTENTION_RETURN_THRESHOLD_MS` (15 s). Fires independently of `dwellTime.enabled`. Use for "Welcome Back" discount modals after comparison shopping. |
|
|
366
|
+
| `user_idle` | `UserIdlePayload` | No user interaction (mouse, keyboard, scroll, touch) for `USER_IDLE_THRESHOLD_MS` (2 min). Fires at most once per idle period. Requires the `LifecycleAdapter` to implement `onInteraction()`. |
|
|
367
|
+
| `user_resumed` | `UserResumedPayload` | First interaction after an idle period. Includes total `idleMs`. The dwell-time baseline is adjusted to exclude the idle gap automatically. |
|
|
368
|
+
| `exit_intent` | `ExitIntentPayload` | User moved the pointer above the viewport top edge **and** the Markov graph has at least one continuation candidate with probability ≥ 0.4. `likelyNext` is the highest-probability next state. Suppressed entirely when no candidates meet the threshold. Requires `LifecycleAdapter.onExitIntent()`. |
|
|
369
|
+
| `conversion` | `ConversionPayload` | `trackConversion()` was called. |
|
|
370
|
+
|
|
371
|
+
**`onError` callback** (in `IntentManagerConfig`)
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
new IntentManager({
|
|
375
|
+
storageKey: 'passive-intent',
|
|
376
|
+
onError: (err: PassiveIntentError) => {
|
|
377
|
+
// Fires on storage quota/security errors and validation failures.
|
|
378
|
+
// err.code: 'STORAGE_READ' | 'STORAGE_WRITE' | 'QUOTA_EXCEEDED' | 'RESTORE_PARSE' | 'SERIALIZE' | 'VALIDATION'
|
|
379
|
+
console.warn('[PassiveIntent]', err.code, err.message);
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### IntentManagerConfig
|
|
385
|
+
|
|
386
|
+
All fields are optional. Pass them to `new IntentManager(config)` or `IntentManager.createAsync(config)`.
|
|
387
|
+
|
|
388
|
+
| Field | Type | Default | Description |
|
|
389
|
+
| ------------------------------- | -------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
390
|
+
| `storageKey` | `string` | `'passive-intent'` | `localStorage` key used to persist the Bloom filter and Markov graph. |
|
|
391
|
+
| `storage` | `StorageAdapter` | `BrowserStorageAdapter` | Synchronous storage backend. Override for custom persistence or tests. |
|
|
392
|
+
| `asyncStorage` | `AsyncStorageAdapter` | — | Async storage backend (React Native, IndexedDB, etc.). Use with `IntentManager.createAsync()`. Takes precedence over `storage` for writes. |
|
|
393
|
+
| `timer` | `TimerAdapter` | `BrowserTimerAdapter` | Timer backend. Override for deterministic tests. |
|
|
394
|
+
| `lifecycleAdapter` | `LifecycleAdapter` | `BrowserLifecycleAdapter` | Page-visibility adapter. Override for React Native, Electron, or SSR environments. |
|
|
395
|
+
| `bloom` | `BloomFilterConfig` | — | Bloom filter sizing: `{ bitSize?: number, hashCount?: number }`. Defaults to 2048 bits / 4 hashes. |
|
|
396
|
+
| `graph` | `MarkovGraphConfig` | — | Markov graph tuning (see sub-fields below). |
|
|
397
|
+
| `graph.highEntropyThreshold` | `number` | `0.75` | Normalized entropy threshold `[0, 1]` above which `high_entropy` fires. |
|
|
398
|
+
| `graph.divergenceThreshold` | `number` | `3.5` | Z-score magnitude for `trajectory_anomaly`. Decrease for more sensitivity. |
|
|
399
|
+
| `graph.baselineMeanLL` | `number` | — | Pre-computed mean of average per-step log-likelihood for normal sessions. Enables Z-score calibration. Also available as top-level `baselineMeanLL` (takes precedence). |
|
|
400
|
+
| `graph.baselineStdLL` | `number` | — | Pre-computed std of average per-step log-likelihood. Pair with `baselineMeanLL`. Also available as top-level `baselineStdLL` (takes precedence). |
|
|
401
|
+
| `graph.smoothingEpsilon` | `number` | `0.01` | Laplace smoothing probability for unseen transitions. |
|
|
402
|
+
| `graph.smoothingAlpha` | `number` | `0.1` | Dirichlet pseudo-count for cold-start regularization. `0` = pure frequentist math. Also available as top-level `smoothingAlpha` (takes precedence). |
|
|
403
|
+
| `graph.maxStates` | `number` | `500` | Maximum live states before LFU pruning triggers. |
|
|
404
|
+
| `baselineMeanLL` | `number` | — | Top-level alias for `graph.baselineMeanLL`. Takes precedence when both are set. |
|
|
405
|
+
| `baselineStdLL` | `number` | — | Top-level alias for `graph.baselineStdLL`. Takes precedence when both are set. |
|
|
406
|
+
| `smoothingAlpha` | `number` | `0.1` | Top-level alias for `graph.smoothingAlpha`. Takes precedence when both are set. |
|
|
407
|
+
| `baseline` | `SerializedMarkovGraph` | — | Pre-trained baseline graph (from `MarkovGraph.toJSON()`). Required for `trajectory_anomaly` detection. |
|
|
408
|
+
| `botProtection` | `boolean` | `true` | Enable EntropyGuard heuristic bot detection. Set `false` in E2E/CI environments. |
|
|
409
|
+
| `dwellTime` | `DwellTimeConfig` | — | Dwell-time anomaly settings: `{ enabled?: boolean, minSamples?: number, zScoreThreshold?: number }`. |
|
|
410
|
+
| `enableBigrams` | `boolean` | `false` | Record second-order (bigram) Markov transitions for more discriminative modeling. |
|
|
411
|
+
| `bigramFrequencyThreshold` | `number` | `5` | Minimum outgoing transitions a unigram state must have before bigram edges are recorded. |
|
|
412
|
+
| `crossTabSync` | `boolean` | `false` | Broadcast verified transitions to other tabs via `BroadcastChannel`. No-op in SSR / unsupported environments. |
|
|
413
|
+
| `persistThrottleMs` | `number` | `0` | Max write frequency for the prune+serialize pipeline. `0` = sync write on every `track()` (full crash-safety). `200–500` recommended for typical graphs. |
|
|
414
|
+
| `persistDebounceMs` | `number` | `2000` | Delay for the async-error retry path and `flushNow()` timer cancellation only. Does not control write frequency for normal `track()` flow. |
|
|
415
|
+
| `eventCooldownMs` | `number` | `0` | Minimum ms between consecutive emissions of the same cooldown-gated event (`high_entropy`, `trajectory_anomaly`, `dwell_time_anomaly`). `0` disables throttling. |
|
|
416
|
+
| `hesitationCorrelationWindowMs` | `number` | `30000` | Max gap (ms) between a `trajectory_anomaly` and a `dwell_time_anomaly` for them to combine into a `hesitation_detected` event. |
|
|
417
|
+
| `driftProtection` | `{ maxAnomalyRate: number; evaluationWindowMs: number }` | `{ maxAnomalyRate: 0.4, evaluationWindowMs: 300000 }` | Killswitch: disables trajectory evaluation when anomaly rate exceeds `maxAnomalyRate` within the rolling window. Set `maxAnomalyRate: 1` to disable. |
|
|
418
|
+
| `holdoutConfig` | `{ percentage: number }` | — | Local A/B holdout: `percentage` (0–100) chance of routing a session to the `'control'` group, which suppresses anomaly events. Visible via `getTelemetry().assignmentGroup`. |
|
|
419
|
+
| `benchmark` | `BenchmarkConfig` | — | Enable op-latency instrumentation: `{ enabled?: boolean, maxSamples?: number }`. Read results via `getPerformanceReport()`. |
|
|
420
|
+
| `onError` | `(error: PassiveIntentError) => void` | — | Non-fatal error callback for storage errors, quota exhaustion, parse failures, and validation errors. The engine never throws to the host. |
|
|
421
|
+
|
|
422
|
+
### Adapters
|
|
423
|
+
|
|
424
|
+
| Export | Kind | Description |
|
|
425
|
+
| ------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
426
|
+
| `BrowserStorageAdapter` | class | Wraps `localStorage`. Use in any browser context. |
|
|
427
|
+
| `BrowserTimerAdapter` | class | Wraps `setTimeout` / `clearTimeout`. |
|
|
428
|
+
| `MemoryStorageAdapter` | class | In-memory fallback — no persistence. Useful for SSR, tests, or ephemeral sessions. |
|
|
429
|
+
| `BrowserLifecycleAdapter` | class | Page Visibility API adapter. Registers a `visibilitychange` listener and dispatches `onPause` / `onResume` callbacks. All `document` accesses are guarded so it is safe to import in SSR. |
|
|
430
|
+
| `StorageAdapter` | interface | Implement to provide a custom storage backend (IndexedDB, Capacitor Preferences, etc.). |
|
|
431
|
+
| `TimerAdapter` | interface | Implement to provide a custom timer backend (e.g. Node.js timers in tests). |
|
|
432
|
+
| `LifecycleAdapter` | interface | Implement to provide a custom page-visibility / app-lifecycle backend for React Native, Electron, or environments where `document` is unavailable. Pass via `IntentManagerConfig.lifecycleAdapter`. |
|
|
433
|
+
| `TimerHandle` | type | Opaque handle returned by `TimerAdapter.setTimeout`. |
|
|
434
|
+
|
|
435
|
+
### Utilities
|
|
436
|
+
|
|
437
|
+
| Export | Signature | Description |
|
|
438
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
439
|
+
| `computeBloomConfig` | `(expectedItems: number, falsePositiveRate: number) => { bitSize, hashCount, estimatedFpRate }` | Pure math helper — compute Bloom parameters without instantiating `BloomFilter`. Tree-shakeable. |
|
|
440
|
+
| `normalizeRouteState` | `(url: string) => string` | Strips query strings/hash fragments, removes trailing slashes, and replaces UUID v4 / MongoDB ObjectID segments with `:id` — call this before `track()` to keep the state space compact. |
|
|
441
|
+
| `MAX_STATE_LENGTH` | `256` (constant) | Hard upper bound on state label length accepted by `BroadcastSync`. Payloads exceeding this are silently dropped. |
|
|
442
|
+
| `MAX_PLAUSIBLE_DWELL_MS` | `1_800_000` (constant, 30 min) | Threshold above which a dwell-time or tab-hidden duration is considered implausible (CPU suspend / OS sleep). Measurements exceeding this are discarded and trigger a `session_stale` event (when `dwellTime.enabled` is `true`). |
|
|
443
|
+
| `ATTENTION_RETURN_THRESHOLD_MS` | `15_000` (constant, 15 s) | Minimum tab-hidden duration before `attention_return` fires. Long enough to filter quick alt-tab glances; short enough to catch comparison shopping. |
|
|
444
|
+
| `USER_IDLE_THRESHOLD_MS` | `120_000` (constant, 2 min) | Duration of user inactivity before `user_idle` fires. Conservative default that avoids false positives from reading or watching embedded video. |
|
|
445
|
+
| `IDLE_CHECK_INTERVAL_MS` | `5_000` (constant, 5 s) | Polling interval for idle-state checks. The `user_idle` event fires within 5 seconds of the actual threshold crossing. CPU overhead is negligible. |
|
|
446
|
+
|
|
447
|
+
### Performance types
|
|
448
|
+
|
|
449
|
+
Exported from `@passiveintent/core` for use with `getPerformanceReport()`. Enable instrumentation via `benchmark: { enabled: true }` in `IntentManagerConfig`.
|
|
450
|
+
|
|
451
|
+
| Type | Description |
|
|
452
|
+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
453
|
+
| `BenchmarkConfig` | `{ enabled?: boolean; maxSamples?: number }` — passed to `IntentManagerConfig.benchmark` to activate latency recording. |
|
|
454
|
+
| `OperationStats` | Per-operation statistics: `{ count, avgMs, p95Ms, p99Ms, maxMs }`. One entry per tracked operation inside `PerformanceReport`. |
|
|
455
|
+
| `MemoryFootprintReport` | Snapshot of engine size: `{ stateCount, totalTransitions, bloomBitsetBytes, serializedGraphBytes }`. |
|
|
456
|
+
| `PerformanceReport` | Full report returned by `getPerformanceReport()`: contains `track`, `bloomAdd`, `bloomCheck`, `incrementTransition`, `entropyComputation`, `divergenceComputation` (`OperationStats` each), plus `memoryFootprint` (`MemoryFootprintReport`) and `benchmarkEnabled`. |
|
|
457
|
+
|
|
458
|
+
### BroadcastSync
|
|
459
|
+
|
|
460
|
+
Cross-tab synchronization over the [BroadcastChannel API](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel). `IntentManager` manages this automatically when `crossTabSync: true` is set in config — you rarely need to use `BroadcastSync` directly.
|
|
461
|
+
|
|
462
|
+
| Method / Property | Description |
|
|
463
|
+
| ----------------------------- | --------------------------------------------------------------------------------------------- |
|
|
464
|
+
| `isActive` | `true` when a real `BroadcastChannel` was opened; `false` in SSR or unsupported environments. |
|
|
465
|
+
| `broadcast(from, to)` | Send a transition to all other tabs on the channel. |
|
|
466
|
+
| `broadcastCounter(key, by)` | Sync a counter increment across tabs. |
|
|
467
|
+
| `applyRemote(from, to)` | Apply a validated remote transition locally (no re-broadcast). |
|
|
468
|
+
| `applyRemoteCounter(key, by)` | Apply a validated remote counter increment locally. |
|
|
469
|
+
| `close()` | Release the channel and remove the message handler. Called by `destroy()`. |
|
|
470
|
+
|
|
471
|
+
### React Wrapper — `@passiveintent/react`
|
|
472
|
+
|
|
473
|
+
A separate package ships a drop-in `usePassiveIntent` hook that manages the full `IntentManager` lifecycle for React 18+, Next.js, and React Router apps:
|
|
474
|
+
|
|
475
|
+
```bash
|
|
476
|
+
npm install @passiveintent/react
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
```tsx
|
|
480
|
+
import { usePassiveIntent } from '@passiveintent/react';
|
|
481
|
+
|
|
482
|
+
const { track, on, getTelemetry, predictNextStates } = usePassiveIntent({
|
|
483
|
+
storageKey: 'passive-intent',
|
|
484
|
+
botProtection: true,
|
|
485
|
+
});
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
The hook is **Strict Mode safe** (instance held in `useRef`), **SSR safe** (`typeof window` guard), and exposes all eight `IntentManager` methods as stable `useCallback` wrappers. See [`packages/react/README.md`](../react/README.md) for the full API table and Next.js / React Router examples.
|
|
489
|
+
|
|
490
|
+
### EntropyGuard (Bot Protection)
|
|
491
|
+
|
|
492
|
+
EntropyGuard tracks the timing of the last 10 `track()` calls using a fixed-size circular buffer (no heap allocations in the hot path). It calculates a `windowBotScore` from the circular buffer when:
|
|
493
|
+
|
|
494
|
+
- A delta between consecutive calls is below **50 ms** (impossibly fast for a human).
|
|
495
|
+
- The variance of recent deltas is below **100 ms²** (robotic, highly regular cadence).
|
|
496
|
+
|
|
497
|
+
When `botScore` reaches **5**, the session is flagged as `isSuspectedBot = true`. While flagged, `evaluateEntropy` and `evaluateTrajectory` return immediately without emitting events — normal navigation state is still recorded.
|
|
498
|
+
|
|
499
|
+
**Configuration:**
|
|
500
|
+
|
|
501
|
+
| Option | Type | Default | Description |
|
|
502
|
+
| --------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
503
|
+
| `botProtection` | `boolean` | `true` | Enable EntropyGuard. Set to `false` in E2E test environments where a headless browser drives clicks programmatically. |
|
|
504
|
+
|
|
505
|
+
**Production usage** (protection on by default):
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
const intent = new IntentManager({ storageKey: 'app' });
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
**E2E / CI usage** (disable so automated clicks reach signal evaluation):
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
const intent = new IntentManager({
|
|
515
|
+
storageKey: 'app',
|
|
516
|
+
botProtection: false,
|
|
517
|
+
});
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### Dirty-Flag Persistence
|
|
521
|
+
|
|
522
|
+
`persist()` checks an internal `isDirty` flag before doing any work. The flag is set to `true` inside `track()` only when:
|
|
523
|
+
|
|
524
|
+
- A new transition is recorded between two states, **or**
|
|
525
|
+
- The Bloom filter is updated with a previously unseen state.
|
|
526
|
+
|
|
527
|
+
After a successful write to storage, the flag is reset to `false`. This means apps that call `flushNow()` or trigger the debounce timer repeatedly without having navigated will incur zero serialization cost.
|
|
528
|
+
|
|
529
|
+
## Design decisions (brief)
|
|
530
|
+
|
|
531
|
+
- **Isomorphic adapters**: direct `window`/`localStorage` usage is avoided in core flow to keep SSR safe.
|
|
532
|
+
- **Memory bounds by default**: `maxStates` defaults to `500`; low-frequency states are pruned first.
|
|
533
|
+
- **Binary graph serialization**: reduces main-thread pressure compared to deep JSON graph snapshots.
|
|
534
|
+
- **Binary persistence contract**: restore expects the current binary payload (`bloomBase64` + `graphBinary`) and safely cold-starts on invalid/corrupt storage.
|
|
535
|
+
- **Predictable anomaly math**:
|
|
536
|
+
- entropy signal from normalized outgoing distribution,
|
|
537
|
+
- trajectory anomaly from baseline log-likelihood window and optional z-score calibration,
|
|
538
|
+
- dwell-time anomaly from Welford's online z-score per state.
|
|
539
|
+
- **Bot-resilient signals**: EntropyGuard uses a fixed circular buffer to detect impossibly-fast or robotic timing patterns without allocating on every `track()` call.
|
|
540
|
+
- **Write-efficient persistence**: the dirty flag eliminates redundant `localStorage` writes when the user has not navigated since the last persist cycle.
|
|
541
|
+
- **Memory-safe bigrams**: selective second-order Markov recording is frequency-gated to prevent state explosion. Only well-established unigram states generate bigram edges, and all states share the same `maxStates` cap with LFU pruning.
|
|
542
|
+
- **Event flood protection**: per-channel cooldown gating ensures downstream consumers are not overwhelmed by rapid sequential anomaly events.
|
|
543
|
+
|
|
544
|
+
## Logic flow (brief)
|
|
545
|
+
|
|
546
|
+
On each `track(state)`:
|
|
547
|
+
|
|
548
|
+
1. If `botProtection` is enabled, record the call timestamp into a circular buffer and evaluate timing patterns.
|
|
549
|
+
2. Check Bloom filter for the state (used to detect new-to-filter states for dirty tracking).
|
|
550
|
+
3. Add state to Bloom filter; mark dirty if the state was new.
|
|
551
|
+
4. Evaluate **dwell-time anomaly** on the previous state (if enabled, not bot-suspected, and enough samples collected).
|
|
552
|
+
5. Add transition from previous state to current state; mark dirty.
|
|
553
|
+
6. If `enableBigrams` is true and the unigram from-state is well-established, record the bigram transition.
|
|
554
|
+
7. Evaluate entropy signal (skipped if bot suspected, or below minimum sample gate).
|
|
555
|
+
8. Evaluate trajectory anomaly (skipped if bot suspected, or below minimum window gate, or no baseline).
|
|
556
|
+
9. Emit `state_change` (always emitted — cooldown applies only to anomaly channels).
|
|
557
|
+
10. **Persist synchronously** (crash-safe write on every `track()` call).
|
|
558
|
+
|
|
559
|
+
During persistence:
|
|
560
|
+
|
|
561
|
+
1. Return immediately if `isDirty` is `false` (no-op — nothing changed).
|
|
562
|
+
2. For async backends: return immediately (setting a pending-write flag) if a write is already in-flight; avoids redundant prune + serialize work.
|
|
563
|
+
3. Prune graph if state count exceeds limit.
|
|
564
|
+
4. Serialize graph to binary.
|
|
565
|
+
5. Encode binary to base64 and store alongside Bloom snapshot.
|
|
566
|
+
6. Reset `isDirty` to `false`.
|
|
567
|
+
|
|
568
|
+
> **`persistDebounceMs`** no longer controls write frequency for normal flow. Every `track()` calls `persist()` synchronously. The debounce value is only consulted by the async-error retry path and `flushNow()` timer cancellation.
|
|
569
|
+
|
|
570
|
+
## Run tests
|
|
571
|
+
|
|
572
|
+
Install project dependencies first:
|
|
573
|
+
|
|
574
|
+
```bash
|
|
575
|
+
npm install
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Run TypeScript build:
|
|
579
|
+
|
|
580
|
+
```bash
|
|
581
|
+
npm run build
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Run unit tests:
|
|
585
|
+
|
|
586
|
+
```bash
|
|
587
|
+
npm test
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Run unit tests with coverage:
|
|
591
|
+
|
|
592
|
+
```bash
|
|
593
|
+
npm run test:coverage
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Run performance suite:
|
|
597
|
+
|
|
598
|
+
```bash
|
|
599
|
+
npm run test:perf
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Cypress E2E tests
|
|
603
|
+
|
|
604
|
+
The E2E suite requires port **3000** to be free. If a previous run crashed without releasing the port, kill the occupying process before re-running.
|
|
605
|
+
|
|
606
|
+
**Headless (CI default):**
|
|
607
|
+
|
|
608
|
+
```bash
|
|
609
|
+
npm run test:e2e
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
**Headed (Chrome, useful for local debugging):**
|
|
613
|
+
|
|
614
|
+
```bash
|
|
615
|
+
npm run test:e2e:headed
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
**Run a single spec directly:**
|
|
619
|
+
|
|
620
|
+
```bash
|
|
621
|
+
npx cypress run --spec "packages/core/cypress/e2e/intent.cy.ts"
|
|
622
|
+
npx cypress run --spec "packages/core/cypress/e2e/amazon.cy.ts"
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
**Open interactive test runner:**
|
|
626
|
+
|
|
627
|
+
```bash
|
|
628
|
+
npx cypress open
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
> **Note — bot protection in the sandbox:** Both `sandbox/app.ts` and `sandbox/amazon/app.ts` initialize `IntentManager` with `botProtection: false`. This is intentional: Cypress drives clicks programmatically in rapid succession, which would otherwise trigger EntropyGuard and suppress the entropy/anomaly toasts that the E2E assertions depend on. Never set `botProtection: false` in a production bundle.
|
|
632
|
+
|
|
633
|
+
## Documentation
|
|
634
|
+
|
|
635
|
+
Full architecture and API deep-dive: [docs/architecture.md](./docs/architecture.md)
|
|
636
|
+
|
|
637
|
+
## Repository structure
|
|
638
|
+
|
|
639
|
+
```
|
|
640
|
+
packages/core/
|
|
641
|
+
├── package.json
|
|
642
|
+
├── tsconfig.json
|
|
643
|
+
├── tsup.config.ts
|
|
644
|
+
├── docs/
|
|
645
|
+
│ └── architecture.md # full architecture & API reference
|
|
646
|
+
├── src/
|
|
647
|
+
│ ├── index.ts
|
|
648
|
+
│ ├── intent-sdk.ts
|
|
649
|
+
│ ├── adapters.ts
|
|
650
|
+
│ ├── core/
|
|
651
|
+
│ │ ├── bloom.ts
|
|
652
|
+
│ │ └── markov.ts
|
|
653
|
+
│ ├── engine/
|
|
654
|
+
│ │ ├── dwell.ts
|
|
655
|
+
│ │ ├── entropy-guard.ts
|
|
656
|
+
│ │ └── intent-manager.ts
|
|
657
|
+
│ ├── persistence/
|
|
658
|
+
│ │ └── codec.ts
|
|
659
|
+
│ ├── sync/
|
|
660
|
+
│ │ └── broadcast-sync.ts
|
|
661
|
+
│ ├── types/
|
|
662
|
+
│ │ └── events.ts
|
|
663
|
+
│ └── utils/
|
|
664
|
+
│ └── route-normalizer.ts
|
|
665
|
+
├── tests/
|
|
666
|
+
│ ├── unit-fast.test.mjs
|
|
667
|
+
│ ├── integration-contract.test.mjs
|
|
668
|
+
│ ├── probabilistic.test.mjs
|
|
669
|
+
│ ├── property-based.test.mjs
|
|
670
|
+
│ └── compatibility-matrix.test.mjs
|
|
671
|
+
├── scripts/
|
|
672
|
+
│ ├── perf-runner.mjs
|
|
673
|
+
│ ├── perf-regression.mjs
|
|
674
|
+
│ ├── roc-experiment.mjs
|
|
675
|
+
│ ├── scenario-matrix.mjs
|
|
676
|
+
│ └── verify-package.mjs
|
|
677
|
+
├── benchmarks/
|
|
678
|
+
├── sandbox/
|
|
679
|
+
│ ├── app.ts
|
|
680
|
+
│ ├── index.html
|
|
681
|
+
│ └── amazon/
|
|
682
|
+
│ ├── app.ts
|
|
683
|
+
│ └── index.html
|
|
684
|
+
└── cypress/
|
|
685
|
+
└── e2e/
|
|
686
|
+
├── intent.cy.ts
|
|
687
|
+
└── amazon.cy.ts
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
## License
|
|
691
|
+
|
|
692
|
+
PassiveIntent is dual-licensed:
|
|
693
|
+
|
|
694
|
+
### AGPLv3 — Free
|
|
695
|
+
|
|
696
|
+
Use PassiveIntent at no cost under the [GNU Affero General Public License v3.0](./LICENSE) if **all** of the following apply:
|
|
697
|
+
|
|
698
|
+
- Your project is open-source **and** you publish the complete source code.
|
|
699
|
+
- You are not incorporating PassiveIntent into a proprietary or closed-source product.
|
|
700
|
+
- If you run PassiveIntent as part of a network service, your entire application is also released under AGPLv3.
|
|
701
|
+
|
|
702
|
+
### Commercial License — Paid
|
|
703
|
+
|
|
704
|
+
A commercial license removes the AGPLv3 copyleft obligations. You need one if:
|
|
705
|
+
|
|
706
|
+
- You ship PassiveIntent inside a **closed-source or proprietary** product.
|
|
707
|
+
- You run it in a **SaaS / network service** without releasing your application source.
|
|
708
|
+
- You re-sell or white-label it inside an analytics or AdTech platform.
|
|
709
|
+
|
|
710
|
+
See [**PRICING.md**](../../PRICING.md) for tier details (Indie · Startup · Growth · Enterprise).
|
|
711
|
+
Contact [support@passiveintent.dev](mailto:support@passiveintent.dev) to purchase a license.
|