diff --git a/CALIBRATION_GUIDE.md b/CALIBRATION_GUIDE.md index 484096d..7330fd9 100644 --- a/CALIBRATION_GUIDE.md +++ b/CALIBRATION_GUIDE.md @@ -236,7 +236,7 @@ engine.on('trajectory_anomaly', (signal) => { ```typescript // Current API — poll predictNextStates after each navigation engine.on('state_change', () => { - const predictions = intent.predictNextStates(0.65); + const predictions = intent.predictNextStates(0.65, (state) => state.startsWith('/articles/')); for (const { state, probability } of predictions) { if (probability > 0.65) { const nextUrl = stateToUrlMap[state]; diff --git a/README.md b/README.md index cb9644a..a53829c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ **Website:** [passiveintent.dev](https://passiveintent.dev) -This repository is structured as an **npm workspaces monorepo** containing all PassiveIntent packages. +This repository is structured as an **npm workspaces monorepo** containing the active PassiveIntent packages. --- @@ -41,6 +41,8 @@ Full documentation for each package lives inside the package directory: - **Remix adapter** — [packages/remix/README.md](./packages/remix/README.md) - **Architecture & API deep-dive** — [packages/core/docs/architecture.md](./packages/core/docs/architecture.md) +Archived exploratory packages still live under `packages/`, but they are intentionally excluded from the active workspace and release flow. + --- ## Repository layout diff --git a/demo-react/DEMO.md b/demo-react/DEMO.md index 1d01ff3..89f5020 100644 --- a/demo-react/DEMO.md +++ b/demo-react/DEMO.md @@ -21,7 +21,7 @@ Built with Vite + TypeScript + the `usePassiveIntent` hook. | Idle Detection | `user_idle` + `user_resumed` | 2-min idle, resume with idleMs | | Exit Intent | `exit_intent` | Smart — requires Markov confidence ≥ 0.4 | | Bloom Filter | `hasSeen()` + `BloomFilter` | O(k) membership, bit visualizer, sizing API | -| Markov Predictions | `predictNextStates()` + `MarkovGraph` | Prefetch next page, binary vs JSON size | +| Markov Predictions | `predictNextStates(threshold, sanitize)` + `MarkovGraph` | Prefetch next page, binary vs JSON size | | Bot Detection | `bot_detected` | EntropyGuard — 5-signal scoring system | | Conversion | `trackConversion()` | Local-only revenue correlation, zero egress | | Counters | `incrementCounter/getCounter/resetCounter` | Session counters, impression capping | diff --git a/demo/DEMO.md b/demo/DEMO.md index c036057..7a1061b 100644 --- a/demo/DEMO.md +++ b/demo/DEMO.md @@ -60,7 +60,7 @@ npm run dev | Idle Detection | `user_idle`, `user_resumed` | | Exit Intent | `exit_intent`, `likelyNext` prediction | | Bloom Filter | `BloomFilter`, `computeBloomConfig()`, `hasSeen()` | -| Markov Predictions | `predictNextStates()`, `MarkovGraph`, binary vs JSON | +| Markov Predictions | `predictNextStates(threshold, sanitize)`, `MarkovGraph`, binary vs JSON | | Bot Detection | `bot_detected`, EntropyGuard | | Conversion Tracking | `trackConversion()`, `conversion` event | | Session Counters | `incrementCounter()`, `getCounter()`, `resetCounter()` | diff --git a/package-lock.json b/package-lock.json index 496fdf9..0444bc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,9 @@ "": { "name": "passiveintent-monorepo", "workspaces": [ - "packages/*", + "packages/core", + "packages/react", + "packages/remix", "demo", "demo-react" ], @@ -99,49 +101,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@angular/common": { - "version": "21.2.5", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.5.tgz", - "integrity": "sha512-MTjCbsHBkF9W12CW9yYiTJdVfZv/qCqBCZ2iqhMpDA5G+ZJiTKP0IDTJVrx2N5iHfiJ1lnK719t/9GXROtEAvg==", - "license": "MIT", - "peer": true, - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/core": "21.2.5", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/core": { - "version": "21.2.5", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.5.tgz", - "integrity": "sha512-JgHU134Adb1wrpyGC9ozcv3hiRAgaFTvJFn1u9OU/AVXyxu4meMmVh2hp5QhAvPnv8XQdKWWIkAY+dbpPE6zKA==", - "license": "MIT", - "peer": true, - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/compiler": "21.2.5", - "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.15.0 || ~0.16.0" - }, - "peerDependenciesMeta": { - "@angular/compiler": { - "optional": true - }, - "zone.js": { - "optional": true - } - } - }, "node_modules/@asamuzakjp/css-color": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", @@ -728,14 +687,6 @@ "ms": "^2.1.1" } }, - "node_modules/@edgesignal/adaptive-ui": { - "resolved": "packages/adaptive-ui", - "link": true - }, - "node_modules/@edgesignal/security": { - "resolved": "packages/security", - "link": true - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1359,10 +1310,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@passiveintent/angular": { - "resolved": "packages/angular", - "link": true - }, "node_modules/@passiveintent/core": { "resolved": "packages/core", "link": true @@ -1375,10 +1322,6 @@ "resolved": "packages/remix", "link": true }, - "node_modules/@passiveintent/vanilla": { - "resolved": "packages/vanilla", - "link": true - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -5224,6 +5167,7 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -5814,6 +5758,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/tsup": { @@ -6451,11 +6396,13 @@ }, "packages/adaptive-ui": { "name": "@edgesignal/adaptive-ui", - "version": "0.0.0" + "version": "0.0.0", + "extraneous": true }, "packages/angular": { "name": "@passiveintent/angular", "version": "0.0.1", + "extraneous": true, "devDependencies": { "typescript": "^5.6.3" }, @@ -6812,11 +6759,13 @@ }, "packages/security": { "name": "@edgesignal/security", - "version": "0.0.0" + "version": "0.0.0", + "extraneous": true }, "packages/vanilla": { "name": "@passiveintent/vanilla", "version": "0.0.1", + "extraneous": true, "dependencies": { "@passiveintent/core": "^1.1.0" }, @@ -6824,472 +6773,6 @@ "esbuild": "^0.25.0", "typescript": "^5.6.3" } - }, - "packages/vanilla/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "packages/vanilla/node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" - } } } } diff --git a/package.json b/package.json index 4476e8b..bd57215 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,9 @@ "name": "passiveintent-monorepo", "private": true, "workspaces": [ - "packages/*", + "packages/core", + "packages/react", + "packages/remix", "demo", "demo-react" ], diff --git a/packages/angular/package.json b/packages/angular/package.json index 310e2be..112abfb 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -2,7 +2,7 @@ "name": "@passiveintent/angular", "version": "0.0.1", "private": true, - "description": "Placeholder — Angular adapter for SAP Spartacus / Composable Storefront. Not yet implemented.", + "description": "Archived placeholder — Angular adapter exploration, not part of the active workspace or release flow.", "type": "module", "main": "./src/index.ts", "scripts": { diff --git a/packages/core/README.md b/packages/core/README.md index 58f8611..82a6dee 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -412,7 +412,7 @@ Inject `IntentService` in your root `AppComponent` (or import it in the root mod | Method | Signature | Description | | ---------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `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. | +| `predictNextStates` | `(threshold?: number, sanitize: (s: string) => boolean) => { state, probability }[]` | Top-N Markov predictions above `threshold` (default `0.3`). `sanitize` is required so prediction consumers fail closed unless they explicitly approve returned routes. | | `hasSeen` | `(state: string) => boolean` | Bloom filter membership test — O(k), no false negatives. | | `getTelemetry` | `() => PassiveIntentTelemetry` | GDPR-safe aggregate snapshot: `sessionId`, `transitionsEvaluated`, `botStatus`, `anomaliesFired`, `engineHealth`, `baselineStatus`, `assignmentGroup`. No raw behavioral data. | | `exportGraph` | `() => SerializedMarkovGraph` | Returns the full Markov graph as a JSON-serializable object. | @@ -662,12 +662,12 @@ Layer 4 — Framework SDKs usePassiveIntent (React hook) wraps IntentMana | Field | Type | Default | Description | | ----------------- | ------------------------------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `storageKey` | `string` | `'passive-intent-engine'` | `localStorage` key for cross-session persistence. | +| `storageKey` | `string` | `'passive-intent'` | `localStorage` key for cross-session persistence. | | `namespace` | `string` | `'passiveintent:'` | Prefix prepended to every `localStorage` key. Use distinct values per micro-frontend to avoid key collisions. | | `baseline` | `SerializedMarkovGraph` | — | Pre-trained graph for `trajectory_anomaly` detection. | | `graph` | `MarkovGraphConfig` | production defaults | Entropy / divergence thresholds, smoothing, state cap. | | `bloom` | `BloomFilterConfig` | `bitSize: 2048, hashCount: 4` | Bloom filter sizing. | -| `stateNormalizer` | `(s: string) => string` | — | Custom normalizer applied after the built-in one. Return `''` to drop a state. | +| `stateNormalizer` | `(s: string) => string` | — | Custom normalizer applied after the built-in one. Return `''` to drop a state; non-string returns are rejected with `VALIDATION`. | | `onError` | `(e: { code: string; message: string }) => void` | — | Non-fatal error callback (storage errors, parse failures). | ### `IntentEngine` — Layer 2 diff --git a/packages/core/cypress/e2e/intent.cy.ts b/packages/core/cypress/e2e/intent.cy.ts index 896fdb0..416eaf4 100644 --- a/packages/core/cypress/e2e/intent.cy.ts +++ b/packages/core/cypress/e2e/intent.cy.ts @@ -411,7 +411,7 @@ describe('Predictive Prefetch Hints', () => { it('Test U: predictNextStates() returns an empty array before any navigation', () => { cy.window().then((win) => { const mgr = (win as any).__intentManager; - const hints = mgr.predictNextStates(0.1); + const hints = mgr.predictNextStates(0.1, () => true); expect(hints).to.deep.equal([]); }); }); @@ -426,7 +426,7 @@ describe('Predictive Prefetch Hints', () => { } // After the loop the last tracked state is /search, so previousState = /search. // /search → /home is the only outgoing edge, so predictions should include /home. - const hints = mgr.predictNextStates(0.1); + const hints = mgr.predictNextStates(0.1, () => true); const states = hints.map((h: { state: string; probability: number }) => h.state); expect(states).to.include('/home'); }); @@ -445,7 +445,7 @@ describe('Predictive Prefetch Hints', () => { mgr.track('/product'); } - const hints = mgr.predictNextStates(0.0); + const hints = mgr.predictNextStates(0.0, () => true); for (let i = 1; i < hints.length; i++) { expect(hints[i].probability).to.be.at.most( hints[i - 1].probability, diff --git a/packages/core/docs/architecture.md b/packages/core/docs/architecture.md index 009d9bd..a8d2836 100644 --- a/packages/core/docs/architecture.md +++ b/packages/core/docs/architecture.md @@ -854,17 +854,13 @@ const intent = new IntentManager({ intent.on('state_change', ({ to }) => { // Predict the most probable next states with probability >= 40 % // Returns { state: string; probability: number }[] sorted descending by probability - const likelyNextStates = intent.predictNextStates(0.4); - - likelyNextStates - // ─── COMPLIANCE GUARDRAIL: see warning below ─────────────────────────────── - .filter(({ state }) => !isSensitiveRoute(state)) - // ─────────────────────────────────────────────────────────────────────────── - .forEach(({ state }) => { - // router.prefetch() fetches the page bundle without navigating to it. - // The page appears to load instantly when the user clicks. - router.prefetch(state); - }); + const likelyNextStates = intent.predictNextStates(0.4, (state) => !isSensitiveRoute(state)); + + likelyNextStates.forEach(({ state }) => { + // router.prefetch() fetches the page bundle without navigating to it. + // The page appears to load instantly when the user clicks. + router.prefetch(state); + }); }); // Track every route change @@ -873,7 +869,7 @@ router.events.on('routeChangeComplete', (url) => intent.track(url)); **Why this works:** -`intent.predictNextStates(threshold)` returns `{ state: string; probability: number }[]` — the outgoing states from the current node whose transition probability exceeds `threshold`, sorted descending by probability. After 5–10 navigations, the Markov graph has enough signal to predict the next page with high accuracy on well-worn paths (e.g., `/dashboard` → `/billing` → `/upgrade`). Combining this with a framework router's prefetch API means those bundles are already in the browser's memory before the user clicks. +`intent.predictNextStates(threshold, sanitize)` returns `{ state: string; probability: number }[]` — the outgoing states from the current node whose transition probability exceeds `threshold`, filtered through your required `sanitize` allowlist and sorted descending by probability. After 5–10 navigations, the Markov graph has enough signal to predict the next page with high accuracy on well-worn paths (e.g., `/dashboard` → `/billing` → `/upgrade`). Combining this with a framework router's prefetch API means those bundles are already in the browser's memory before the user clicks. The result is not just a performance gain — it is a **retention signal**. A fast, responsive app has measurably lower bounce rates. PassiveIntent turns behavioral data that was already being collected for churn detection into a free performance dividend. @@ -1199,7 +1195,7 @@ import type { IntentManagerConfig, UsePassiveIntentReturn } from '@passiveintent | `track` | `(event: string) => void` | no-op before mount / after unmount | | `on` | `(event, handler) => () => void` | returns a NOOP unsubscribe on SSR | | `getTelemetry` | `() => PassiveIntentTelemetry` | empty object cast before mount | -| `predictNextStates` | `(threshold?, sanitize?) => { state: string; probability: number }[]` | `[]` before first mount | +| `predictNextStates` | `(threshold?, sanitize) => { state: string; probability: number }[]` | `[]` before first mount | | `hasSeen` | `(route: string) => boolean` | `false` before first mount | | `incrementCounter` | `(key: string, by?: number) => number` | returns new value; `0` before mount / SSR | | `getCounter` | `(key: string) => number` | `0` before first mount | @@ -1332,7 +1328,7 @@ interface IntentEngineConfig { persistence: IPersistenceAdapter; lifecycle: ILifecycleAdapter; input?: IInputAdapter; // optional — or drive manually via track() - storageKey?: string; // default: 'passive-intent-engine' + storageKey?: string; // default: 'passive-intent' stateNormalizer?: (state: string) => string; onError?: (error: { code: string; message: string }) => void; } @@ -1341,13 +1337,13 @@ interface IntentEngineConfig { #### Standard Web Plugins `src/plugins/web/` ships four concrete implementations for the microkernel -`IntentEngine`. These adapters are **browser-specific** — they depend on +`IntentEngine`, published via `@passiveintent/core/plugins/web`. These adapters are **browser-specific** — they depend on `document`, `window.localStorage`, and browser navigation APIs. They are suitable only for standard browser environments. Non-browser hosts such as React Native or Electron (when using a native shell rather than a web view) require custom adapter implementations that satisfy the same four interfaces (`IInputAdapter`, `ILifecycleAdapter`, `IStateModel`, `IPersistenceAdapter`) -using platform-native APIs — the `src/plugins/web/` adapters cannot be +using platform-native APIs — the `@passiveintent/core/plugins/web` adapters cannot be reused on those platforms. | File | Implements | Mechanism | diff --git a/packages/core/package.json b/packages/core/package.json index 04c5ca4..d776da2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -49,13 +49,23 @@ "types": "./dist/calibration.d.ts", "default": "./dist/calibration.cjs" } + }, + "./plugins/web": { + "import": { + "types": "./dist/plugins/web/index.d.ts", + "default": "./dist/plugins/web/index.js" + }, + "require": { + "types": "./dist/plugins/web/index.d.ts", + "default": "./dist/plugins/web/index.cjs" + } } }, "files": [ - "dist/*.js", - "dist/*.js.map", - "dist/*.cjs", - "dist/*.cjs.map", + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.cjs", + "dist/**/*.cjs.map", "dist/**/*.d.ts", "dist/**/*.d.ts.map" ], diff --git a/packages/core/scripts/verify-package.mjs b/packages/core/scripts/verify-package.mjs index b21c754..9a1ea8d 100644 --- a/packages/core/scripts/verify-package.mjs +++ b/packages/core/scripts/verify-package.mjs @@ -28,11 +28,21 @@ try { const smoke = ` import { IntentManager, MarkovGraph, BloomFilter } from '@passiveintent/core'; +import { + BrowserLifecycleAdapter, + ContinuousGraphModel, + LocalStorageAdapter, + MouseKinematicsAdapter, +} from '@passiveintent/core/plugins/web'; const g = new MarkovGraph(); g.incrementTransition('home', 'search'); const b = new BloomFilter(); b.add('home'); const m = new IntentManager({ storageKey: 'smoke-test', botProtection: false }); +new ContinuousGraphModel(); +new LocalStorageAdapter(); +new BrowserLifecycleAdapter(); +new MouseKinematicsAdapter(); m.track('home'); m.track('search'); if (!b.check('home') || g.getProbability('home', 'search') <= 0) { diff --git a/packages/core/src/defaults.ts b/packages/core/src/defaults.ts new file mode 100644 index 0000000..1f7cff2 --- /dev/null +++ b/packages/core/src/defaults.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) 2026 Purushottam + * + * This source code is licensed under the AGPL-3.0-only license found in the + * LICENSE file in the root directory of this source tree. + */ + +export const DEFAULT_STORAGE_KEY = 'passive-intent'; +export const DEFAULT_NAMESPACE = 'passiveintent:'; diff --git a/packages/core/src/engine/config-normalizer.ts b/packages/core/src/engine/config-normalizer.ts index bfdeefe..230e0d0 100644 --- a/packages/core/src/engine/config-normalizer.ts +++ b/packages/core/src/engine/config-normalizer.ts @@ -6,6 +6,7 @@ */ import type { IntentManagerConfig, MarkovGraphConfig } from '../types/events.js'; +import { DEFAULT_NAMESPACE, DEFAULT_STORAGE_KEY } from '../defaults.js'; import type { EnginePolicy } from './policies/engine-policy.js'; import { SMOOTHING_EPSILON } from './constants.js'; @@ -143,8 +144,8 @@ export function buildIntentManagerOptions( : SMOOTHING_EPSILON; // ── Persistence ───────────────────────────────────────────────────────── - const storageKey = config.storageKey ?? 'passive-intent'; - const namespace = typeof config.namespace === 'string' ? config.namespace : 'passiveintent:'; + const storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY; + const namespace = typeof config.namespace === 'string' ? config.namespace : DEFAULT_NAMESPACE; const rawPersistDebounce = config.persistDebounceMs; const persistDebounceMs = diff --git a/packages/core/src/engine/intent-engine.ts b/packages/core/src/engine/intent-engine.ts index a9eae71..65f057e 100644 --- a/packages/core/src/engine/intent-engine.ts +++ b/packages/core/src/engine/intent-engine.ts @@ -32,8 +32,9 @@ import type { IPersistenceAdapter, } from '../types/microkernel.js'; import type { IntentEventMap } from '../types/events.js'; +import { DEFAULT_STORAGE_KEY } from '../defaults.js'; import { EventEmitter } from './event-emitter.js'; -import { normalizeRouteState } from '../utils/route-normalizer.js'; +import { resolveTrackedState } from '../utils/tracked-state.js'; /** Maximum trajectory window kept for signal evaluation. */ const TRAJECTORY_WINDOW = 20; @@ -61,7 +62,7 @@ export class IntentEngine { this.persistence = config.persistence; this.lifecycle = config.lifecycle; this.input = config.input; - this.storageKey = config.storageKey ?? 'passive-intent-engine'; + this.storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY; this.stateNormalizer = config.stateNormalizer; this.onError = config.onError; @@ -261,37 +262,8 @@ export class IntentEngine { */ private _processState(raw: string): void { // ── Normalize ──────────────────────────────────────────────────────────── - let state = normalizeRouteState(raw); - - if (this.stateNormalizer) { - try { - const normalized = this.stateNormalizer(state); - if (typeof normalized !== 'string') { - this.onError?.({ - code: 'VALIDATION', - message: `IntentEngine.track(): stateNormalizer must return a string, got ${typeof normalized}`, - }); - return; - } - // Empty string is a deliberate "skip this state" signal. - if (normalized === '') return; - state = normalized; - } catch (err) { - this.onError?.({ - code: 'VALIDATION', - message: `IntentEngine.track(): stateNormalizer threw: ${ - err instanceof Error ? err.message : String(err) - }`, - }); - return; - } - } - - if (state === '') { - this.onError?.({ - code: 'VALIDATION', - message: 'IntentEngine.track(): state label must not be an empty string', - }); + const state = resolveTrackedState(raw, 'IntentEngine', this.stateNormalizer, this.onError); + if (state === null) { return; } diff --git a/packages/core/src/engine/intent-manager.ts b/packages/core/src/engine/intent-manager.ts index 9071136..f44fa46 100644 --- a/packages/core/src/engine/intent-manager.ts +++ b/packages/core/src/engine/intent-manager.ts @@ -9,9 +9,10 @@ import { BenchmarkRecorder } from '../performance-instrumentation.js'; import type { PerformanceReport } from '../performance-instrumentation.js'; import { BrowserStorageAdapter, BrowserTimerAdapter } from '../adapters.js'; import type { AsyncStorageAdapter, StorageAdapter, TimerAdapter } from '../adapters.js'; +import { DEFAULT_STORAGE_KEY } from '../defaults.js'; import { BloomFilter } from '../core/bloom.js'; import { MarkovGraph } from '../core/markov.js'; -import { normalizeRouteState } from '../utils/route-normalizer.js'; +import { resolveTrackedState } from '../utils/tracked-state.js'; import type { SerializedMarkovGraph } from '../core/markov.js'; import type { ConversionPayload, @@ -286,7 +287,7 @@ export class IntentManager { } // Use the same default storage key as the config normalizer without // incurring a second full normalization pass. - const storageKey = config.storageKey ?? 'passive-intent'; + const storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY; // Await the single I/O call up-front so the constructor stays synchronous. const raw = await config.asyncStorage.getItem(storageKey); @@ -322,41 +323,11 @@ export class IntentManager { * ``` */ track(state: string): void { - // Normalise first: strip query strings, hash fragments, trailing slashes, - // and replace dynamic ID segments (UUIDs, MongoDB ObjectIDs, numeric IDs) with ':id'. - state = normalizeRouteState(state); - - // Apply optional custom normalizer (e.g. for SEO slugs). - if (this.stateNormalizer) { - try { - const normalized = this.stateNormalizer(state); - const coerced = String(normalized); - // Empty string is a deliberate "skip this state" signal from the - // normalizer — drop silently without firing a VALIDATION error. - if (coerced === '') return; - state = coerced; - } catch (err) { - if (this.onError) { - this.onError({ - code: 'VALIDATION', - message: `IntentManager.track(): stateNormalizer threw: ${err instanceof Error ? err.message : String(err)}`, - }); - } - return; - } - } - - // Guard: '' is reserved internally as a tombstone marker. - // Silently drop and surface a non-fatal error rather than crashing the host. - if (state === '') { - if (this.onError) { - this.onError({ - code: 'VALIDATION', - message: 'IntentManager.track(): state label must not be an empty string', - }); - } + const normalizedState = resolveTrackedState(state, 'IntentManager', this.stateNormalizer, this.onError); + if (normalizedState === null) { return; } + state = normalizedState; const now = this.timer.now(); const trackStart = this.benchmark.now(); @@ -524,10 +495,9 @@ export class IntentManager { * * @param threshold Minimum probability in [0, 1] for a state to be included. * Defaults to `0.3`. - * @param sanitize Optional predicate that receives each candidate state label + * @param sanitize Required predicate that receives each candidate state label * and returns `true` to **include** it or `false` to **exclude** - * it. When omitted all states above the threshold are returned, - * which is **unsafe** for production use — always supply this. + * it. When omitted the method fails closed and returns `[]`. * @returns Filtered and sorted `{ state, probability }[]`, descending by * probability. Returns an empty array when no previous state is known * or no transitions meet the threshold. @@ -537,9 +507,28 @@ export class IntentManager { sanitize?: (state: string) => boolean, ): { state: string; probability: number }[] { if (this.previousState === null) return []; + if (typeof sanitize !== 'function') { + this.onError?.({ + code: 'VALIDATION', + message: + 'IntentManager.predictNextStates(): sanitize must be provided; returning [] to fail closed', + }); + return []; + } const candidates = this.graph.getLikelyNextStates(this.previousState, threshold); - if (!sanitize) return candidates; - return candidates.filter(({ state }) => sanitize(state)); + return candidates.filter(({ state }) => { + try { + return sanitize(state); + } catch (err) { + this.onError?.({ + code: 'VALIDATION', + message: `IntentManager.predictNextStates(): sanitize threw: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + return false; + } + }); } flushNow(): void { diff --git a/packages/core/src/factory.ts b/packages/core/src/factory.ts index 075a2ab..aa9fec1 100644 --- a/packages/core/src/factory.ts +++ b/packages/core/src/factory.ts @@ -43,6 +43,7 @@ import { IntentManager } from './engine/intent-manager.js'; import type { MarkovGraphConfig, BloomFilterConfig } from './types/events.js'; import type { SerializedMarkovGraph } from './core/markov.js'; +import { DEFAULT_STORAGE_KEY } from './defaults.js'; /* ------------------------------------------------------------------ */ /* BrowserConfig */ @@ -62,7 +63,7 @@ export interface BrowserConfig { * Use a unique key per application to avoid collisions when multiple * PassiveIntent instances share the same origin. * - * Default: `'passive-intent-engine'` + * Default: `'passive-intent'` */ storageKey?: string; @@ -161,7 +162,7 @@ export interface BrowserConfig { */ export function createBrowserIntent(config: BrowserConfig = {}): IntentManager { return new IntentManager({ - storageKey: config.storageKey ?? 'passive-intent-engine', + storageKey: config.storageKey ?? DEFAULT_STORAGE_KEY, namespace: config.namespace, baseline: config.baseline, graph: config.graph, diff --git a/packages/core/src/plugins/web/index.ts b/packages/core/src/plugins/web/index.ts new file mode 100644 index 0000000..1ec0103 --- /dev/null +++ b/packages/core/src/plugins/web/index.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2026 Purushottam + * + * This source code is licensed under the AGPL-3.0-only license found in the + * LICENSE file in the root directory of this source tree. + */ + +export { BrowserLifecycleAdapter } from './BrowserLifecycleAdapter.js'; +export { ContinuousGraphModel } from './ContinuousGraphModel.js'; +export { LocalStorageAdapter } from './LocalStorageAdapter.js'; +export { MouseKinematicsAdapter } from './MouseKinematicsAdapter.js'; diff --git a/packages/core/src/types/events.ts b/packages/core/src/types/events.ts index aef953a..9b7012d 100644 --- a/packages/core/src/types/events.ts +++ b/packages/core/src/types/events.ts @@ -513,7 +513,9 @@ export interface IntentManagerConfig { * ``` * * The return value of this function becomes the canonical state label. - * Returning an empty string causes the `track()` call to be silently dropped. + * Returning any non-string value triggers a `VALIDATION` error and drops the + * `track()` call. Returning an empty string causes the `track()` call to be + * silently dropped. */ stateNormalizer?: (state: string) => string; /** diff --git a/packages/core/src/types/microkernel.ts b/packages/core/src/types/microkernel.ts index c0be459..3f4a776 100644 --- a/packages/core/src/types/microkernel.ts +++ b/packages/core/src/types/microkernel.ts @@ -254,7 +254,7 @@ export interface IntentEngineConfig { input?: IInputAdapter; /** * Storage key used by `IPersistenceAdapter`. - * Default: `'passive-intent-engine'`. + * Default: `'passive-intent'`. */ storageKey?: string; /** diff --git a/packages/core/src/utils/tracked-state.ts b/packages/core/src/utils/tracked-state.ts new file mode 100644 index 0000000..4fb4be6 --- /dev/null +++ b/packages/core/src/utils/tracked-state.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2026 Purushottam + * + * This source code is licensed under the AGPL-3.0-only license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { normalizeRouteState } from './route-normalizer.js'; +import type { PassiveIntentError } from '../types/events.js'; + +type ValidationError = Pick & { code: 'VALIDATION' }; + +export function resolveTrackedState( + raw: string, + owner: 'IntentEngine' | 'IntentManager', + stateNormalizer: ((state: string) => string) | undefined, + onError?: (error: ValidationError) => void, +): string | null { + let state = normalizeRouteState(raw); + + if (stateNormalizer) { + try { + const normalized = stateNormalizer(state); + if (typeof normalized !== 'string') { + onError?.({ + code: 'VALIDATION', + message: `${owner}.track(): stateNormalizer must return a string, got ${typeof normalized}`, + }); + return null; + } + if (normalized === '') return null; + state = normalized; + } catch (err) { + onError?.({ + code: 'VALIDATION', + message: `${owner}.track(): stateNormalizer threw: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + return null; + } + } + + if (state === '') { + onError?.({ + code: 'VALIDATION', + message: `${owner}.track(): state label must not be an empty string`, + }); + return null; + } + + return state; +} diff --git a/packages/core/tests/microkernel.test.mjs b/packages/core/tests/microkernel.test.mjs index 2121411..8c7fbfb 100644 --- a/packages/core/tests/microkernel.test.mjs +++ b/packages/core/tests/microkernel.test.mjs @@ -24,9 +24,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { IntentEngine } from '../dist/src/engine/intent-engine.js'; -import { ContinuousGraphModel } from '../dist/src/plugins/web/ContinuousGraphModel.js'; -import { LocalStorageAdapter } from '../dist/src/plugins/web/LocalStorageAdapter.js'; -import { MouseKinematicsAdapter } from '../dist/src/plugins/web/MouseKinematicsAdapter.js'; +import { + ContinuousGraphModel, + LocalStorageAdapter, + MouseKinematicsAdapter, +} from '../dist/plugins/web/index.js'; import { createBrowserIntent } from '../dist/src/factory.js'; // --------------------------------------------------------------------------- @@ -97,7 +99,7 @@ function makeModel({ function makePersistence({ stored = null, throwOnSave = false } = {}) { const calls = { load: 0, save: /** @type {string[]} */ ([]) }; const store = new Map(); - if (stored !== null) store.set('passive-intent-engine', stored); + if (stored !== null) store.set('passive-intent', stored); return { persistence: { load(key) { diff --git a/packages/core/tests/unit-fast.test.mjs b/packages/core/tests/unit-fast.test.mjs index 02d90fd..811e2a7 100644 --- a/packages/core/tests/unit-fast.test.mjs +++ b/packages/core/tests/unit-fast.test.mjs @@ -2828,7 +2828,7 @@ test('MarkovGraph.getLikelyNextStates returns empty array when threshold exceeds assert.deepEqual(graph.getLikelyNextStates('/home', 1.1), []); }); -test('IntentManager.predictNextStates returns likely next states from previousState', () => { +test('IntentManager.predictNextStates returns likely next states from previousState when sanitize is provided', () => { storage.clear(); const manager = new IntentManager({ storageKey: 'predict-basic', storage, botProtection: false }); manager.track('/home'); @@ -2838,7 +2838,7 @@ test('IntentManager.predictNextStates returns likely next states from previousSt manager.track('/home'); // Now previousState = '/home', graph has /home → /products with high probability - const hints = manager.predictNextStates(0.3); + const hints = manager.predictNextStates(0.3, () => true); assert.ok(hints.length > 0); assert.ok(hints.some(({ state }) => state === '/products')); manager.flushNow(); @@ -2847,7 +2847,7 @@ test('IntentManager.predictNextStates returns likely next states from previousSt test('IntentManager.predictNextStates returns empty array before any state is tracked', () => { storage.clear(); const manager = new IntentManager({ storageKey: 'predict-empty', storage, botProtection: false }); - assert.deepEqual(manager.predictNextStates(0.3), []); + assert.deepEqual(manager.predictNextStates(0.3, () => true), []); manager.flushNow(); }); @@ -2871,6 +2871,26 @@ test('IntentManager.predictNextStates applies sanitize predicate to filter resul manager.flushNow(); }); +test('IntentManager.predictNextStates fails closed when sanitize is omitted', () => { + storage.clear(); + const errors = []; + const manager = new IntentManager({ + storageKey: 'predict-fail-closed', + storage, + botProtection: false, + onError: (error) => errors.push(error), + }); + manager.track('/home'); + manager.track('/products'); + manager.track('/home'); + + assert.deepEqual(manager.predictNextStates(0.1), []); + assert.equal(errors.length, 1); + assert.equal(errors[0].code, 'VALIDATION'); + assert.match(errors[0].message, /sanitize must be provided/); + manager.flushNow(); +}); + test('IntentManager.predictNextStates uses default threshold of 0.3', () => { storage.clear(); const manager = new IntentManager({ @@ -2888,7 +2908,7 @@ test('IntentManager.predictNextStates uses default threshold of 0.3', () => { manager.track('/home'); // previousState = '/home' - const hints = manager.predictNextStates(); // default threshold = 0.3 + const hints = manager.predictNextStates(undefined, () => true); // default threshold = 0.3 assert.ok( hints.some(({ state }) => state === '/common'), '/common should be included', @@ -5343,12 +5363,14 @@ test('stateNormalizer: throwing normalizer drops the track() call and fires onEr manager.flushNow(); }); -test('stateNormalizer: non-string return is coerced to string and tracked normally', () => { +test('stateNormalizer: non-string return is rejected and drops the track() call', () => { storage.clear(); + const errors = []; const manager = new IntentManager({ storageKey: 'normalizer-nonstring-test', storage, botProtection: false, + onError: (err) => errors.push(err), // @ts-ignore — intentional: simulate a JS caller returning a number stateNormalizer: () => 42, }); @@ -5356,8 +5378,10 @@ test('stateNormalizer: non-string return is coerced to string and tracked normal manager.on('state_change', ({ to }) => changes.push(to)); manager.track('/home'); - assert.equal(changes.length, 1, 'track() must succeed after string coercion'); - assert.equal(changes[0], '42', 'state must be the string-coerced return value'); + assert.equal(changes.length, 0, 'track() must be dropped for non-string normalizer output'); + assert.equal(errors.length, 1, 'onError must fire once for non-string output'); + assert.equal(errors[0].code, 'VALIDATION'); + assert.match(errors[0].message, /stateNormalizer must return a string/); manager.flushNow(); }); diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 6d07fc0..ebe6d4e 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -8,7 +8,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts', 'src/calibration.ts'], + entry: ['src/index.ts', 'src/calibration.ts', 'src/plugins/web/index.ts'], format: ['esm', 'cjs'], dts: false, splitting: false, diff --git a/packages/react/README.md b/packages/react/README.md index 5f5fa4b..42630f9 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -173,7 +173,7 @@ All returned methods are stable across re-renders. | `track` | `(state: string) => void` | Records a page view or custom state transition. | | `on` | `(event, listener) => () => void` | Typed subscription API. Returns a no-op unsubscribe during SSR. | | `getTelemetry` | `() => PassiveIntentTelemetry` | Returns a fully shaped zero-value object until the engine is live. | -| `predictNextStates` | `(threshold?, sanitize?) => { state: string; probability: number }[]` | Sorted Markov predictions. | +| `predictNextStates` | `(threshold?, sanitize) => { state: string; probability: number }[]` | Sorted Markov predictions. `sanitize` is required so callers fail closed by default. | | `hasSeen` | `(state: string) => boolean` | Bloom filter membership test. | | `incrementCounter` | `(key: string, by?: number) => number` | Exact session counter increment. | | `getCounter` | `(key: string) => number` | Reads a session counter. | @@ -194,7 +194,7 @@ All hooks in this section require a `PassiveIntentProvider` ancestor. | `useSignals()` | `{ exitIntent, idle, attentionReturn }` | Convenience composition of the three signal hooks above. | | `usePropensity(targetState, options?, select?)` | `number` | Single-hop conversion score with dwell-time friction. | | `usePropensityScore(targetState, options?, select?)` | `number` | **Deprecated.** Use `usePropensity` instead — identical signature, safer under concurrent rendering. See migration note below. | -| `usePredictiveLink(options?)` | `{ predictions }` | Reads `predictNextStates()` on navigation and can inject `` tags. | +| `usePredictiveLink(options?)` | `{ predictions }` | Reads `predictNextStates()` on navigation and can inject `` tags. Provide `sanitize` to opt in; otherwise it fails closed with no predictions. | | `useEventLog(events, options?)` | `{ log, clear }` | Bounded reverse-chronological log of selected engine events. | ### `useRouteTracker` @@ -251,7 +251,7 @@ const tier = usePropensity('/checkout', undefined, (s) => | ---------------------- | ------------------------------------ | | `usePropensity()` | `alpha = 0.2` | | `usePropensityScore()` | `alpha = 0.2` _(deprecated)_ | -| `usePredictiveLink()` | `threshold = 0.3`, `prefetch = true` | +| `usePredictiveLink()` | `threshold = 0.3`, `prefetch = true`, `sanitize = deny-all` | | `useEventLog()` | `maxEntries = 100` | ### Migrating from `usePropensityScore` to `usePropensity` @@ -431,7 +431,7 @@ SSR support is explicit: track(state); // no-op on(event, listener); // returns a no-op unsubscribe getTelemetry(); // returns TELEMETRY_DEFAULT -predictNextStates(); // [] +predictNextStates(0.3, () => true); // [] hasSeen(state); // false ``` diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index 9906eb6..f2c60ec 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -42,6 +42,9 @@ import { PassiveIntentContext } from './context.js'; // ── Shared ──────────────────────────────────────────────────────────────────── +const ALLOW_ALL_STATES = () => true; +const DENY_ALL_STATES = () => false; + const providerError = (hook: string) => `[PassiveIntent] ${hook}() must be used within a .`; @@ -572,7 +575,7 @@ export function usePropensity( if (!ctx) return () => {}; const recompute = () => { - const predictions = ctx.predictNextStates(0); + const predictions = ctx.predictNextStates(0, ALLOW_ALL_STATES); const target = predictions.find((p) => p.state === targetStateRef.current); const base = target?.probability ?? 0; const z = Math.max(0, zScoreRef.current); @@ -651,7 +654,7 @@ export interface UsePropensityScoreOptions { * - React's tearing detection works correctly: repeated `getSnapshot()` calls * during the same render return the same `number` (refs don't change * mid-render; `Object.is` comparisons on scalars are exact). - * - The computation is **pure and cheap** — a single `predictNextStates(0)` + * - The computation is **pure and cheap** — a single `predictNextStates(0, allowAll)` * lookup (O(transitions from current state)), not a full graph traversal. * * **Multi-hop / BFS propensity:** For path-based propensity that traverses @@ -739,7 +742,7 @@ export function usePropensityScore( // via Object.is, so equal scores never schedule a re-render. const getSnapshot = useCallback((): number => { if (!ctx) return 0; - const predictions = ctx.predictNextStates(0); + const predictions = ctx.predictNextStates(0, ALLOW_ALL_STATES); const target = predictions.find((p) => p.state === targetStateRef.current); const base = target?.probability ?? 0; const z = Math.max(0, zScoreRef.current); @@ -778,7 +781,7 @@ export interface UsePredictiveLinkOptions { * @default 0.3 */ threshold?: number; - /** Filter predicate to exclude sensitive routes from prefetching. */ + /** Filter predicate to exclude sensitive routes from prefetching. When omitted, the hook fails closed and returns no predictions. */ sanitize?: (state: string) => boolean; /** * Automatically inject `` into `document.head` for @@ -804,10 +807,7 @@ export interface UsePredictiveLinkReturn { * * @example * ```tsx - * // Auto-prefetch with defaults (threshold 0.3, prefetch enabled) - * const { predictions } = usePredictiveLink(); - * - * // Display predictions + exclude admin routes from prefetching + * // Auto-prefetch with an explicit allowlist * const { predictions } = usePredictiveLink({ * threshold: 0.4, * sanitize: (s) => !s.startsWith('/admin'), @@ -830,7 +830,10 @@ export function usePredictiveLink(options?: UsePredictiveLinkOptions): UsePredic (onStoreChange: () => void) => { if (!ctx) return () => {}; return ctx.on('state_change', () => { - snapshotRef.current = ctx.predictNextStates(thresholdRef.current, sanitizeRef.current); + snapshotRef.current = ctx.predictNextStates( + thresholdRef.current, + sanitizeRef.current ?? DENY_ALL_STATES, + ); onStoreChange(); }); }, diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index ea56ea1..1dbb27c 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -52,8 +52,8 @@ export interface UsePassiveIntentReturn { /** * Returns `{ state, probability }[]` sorted descending by probability for * all next states whose transition probability exceeds `threshold` (default - * `0.3`). Pass a `sanitize` predicate to exclude sensitive or state-mutating - * routes before using results for prefetching — see architecture docs. + * `0.3`). A `sanitize` predicate is required so callers fail closed unless + * they explicitly approve returned routes for their use case. * * Returns an empty array during SSR or before the first `track()` call. */ diff --git a/packages/react/tests/ssr-safety.test.ts b/packages/react/tests/ssr-safety.test.ts index 574eb7e..d45a7c2 100644 --- a/packages/react/tests/ssr-safety.test.ts +++ b/packages/react/tests/ssr-safety.test.ts @@ -83,7 +83,7 @@ describe('SSR safety', () => { expect(result.current.getTelemetry().botStatus).toBe('human'); expect(result.current.getTelemetry().engineHealth).toBe('healthy'); expect(result.current.getTelemetry().transitionsEvaluated).toBe(0); - expect(result.current.predictNextStates()).toEqual([]); + expect(result.current.predictNextStates(0.3, () => true)).toEqual([]); expect(result.current.hasSeen('/any')).toBe(false); expect(result.current.getCounter('x')).toBe(0); expect(result.current.incrementCounter('x')).toBe(0); @@ -120,7 +120,7 @@ describe('SSR safety', () => { expect(() => result.current.track('/page')).not.toThrow(); expect(result.current.getTelemetry().sessionId).toBe(''); - expect(result.current.predictNextStates()).toEqual([]); + expect(result.current.predictNextStates(0.3, () => true)).toEqual([]); expect(result.current.hasSeen('/x')).toBe(false); expect(result.current.getCounter('k')).toBe(0); diff --git a/packages/react/tests/use-passive-intent.test.ts b/packages/react/tests/use-passive-intent.test.ts index ca01a9a..ac8c74c 100644 --- a/packages/react/tests/use-passive-intent.test.ts +++ b/packages/react/tests/use-passive-intent.test.ts @@ -199,7 +199,7 @@ describe('usePassiveIntent', () => { const { result, unmount } = renderHook(() => usePassiveIntent(BASE_CONFIG)); unmount(); - expect(result.current.predictNextStates()).toEqual([]); + expect(result.current.predictNextStates(0.3, () => true)).toEqual([]); }); it('hasSeen() returns false', () => { diff --git a/packages/vanilla/package.json b/packages/vanilla/package.json index 2e2983b..5de1738 100644 --- a/packages/vanilla/package.json +++ b/packages/vanilla/package.json @@ -2,7 +2,7 @@ "name": "@passiveintent/vanilla", "version": "0.0.1", "private": true, - "description": "Placeholder — Vanilla JS IIFE/UMD adapter for GTM, Wix Velo, Squarespace code injection. Not yet implemented.", + "description": "Archived placeholder — Vanilla adapter exploration, not part of the active workspace or release flow.", "type": "module", "main": "./src/index.ts", "scripts": {