diff --git a/.gitignore b/.gitignore index 556d04d..2a90a96 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,8 @@ scripts/contract-gate/node_modules/ scripts/contract-gate/build/ scripts/specifics-bank/node_modules/ scripts/specifics-bank/build/ +scripts/trends/node_modules/ +scripts/trends/build/ # --- session/local state --- # STATE.md is LOCAL-ONLY (gitignored): no private remote exists and STATE must diff --git a/scripts/trends/README.md b/scripts/trends/README.md new file mode 100644 index 0000000..d1c63d4 --- /dev/null +++ b/scripts/trends/README.md @@ -0,0 +1,73 @@ +# linkedin-trends-store + +Persistent **trend store** — the foundation layer of the research engine +(retning §5). A topic-tagged, provenance-bearing inventory of trend signals +captured over time, so the engine accumulates **history** instead of starting +amnesiac each session. + +Twin of [`scripts/specifics-bank`](../specifics-bank): same deterministic +store / dedup / query discipline, different dedupe key — a trend is identified by +its **normalized title+URL**, not by free-text content. + +## Generic by architecture + +Nothing niche-specific lives here. A `TrendRecord` carries free-form `topics` +tags and a free-form `source` string; *which* topics matter and *which* sources +to poll are decided upstream (config/profile + the capture agent), never +hard-coded in this module. The same store serves any niche. + +## Data location + +The store lives under the per-user data dir (M0 data-path convention), so trend +history survives plugin upgrades/reinstalls: + +``` +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/trends.json +``` + +`LINKEDIN_STUDIO_DATA` overrides the root. No path is hard-coded in prose. + +## Record shape (minimal generic core) + +```ts +interface TrendRecord { + id: string; // sha256(normalized title+url).slice(0,12) — also the dedupe key + title: string; // headline, verbatim + url: string; // source URL, verbatim + source: string; // "tavily" | "websearch" | "manual" | + capturedAt: string; // ISO-8601 date + topics: string[]; // query tags; unioned across re-captures + summary?: string; // optional, verbatim +} +``` + +Fields (relevance score, first-mover timing, status) can be added in a later +slice without breaking the shape. + +## CLI + +```bash +# Capture a freshly-polled trend (dedupes on title+url; unions topics on re-capture) +node --import tsx src/cli.ts add \ + --title "Agentic workflows hit production" \ + --url "https://example.com/agentic" \ + --topics "agents,engineering" --source tavily \ + --summary "Teams ship multi-step agents past the demo stage." + +# Topic-scoped history — trends matching these topics, ranked by overlap then recency +node --import tsx src/cli.ts query --topics "agents,engineering" [--json] + +# Time-scoped history — newest first, optionally windowed/capped +node --import tsx src/cli.ts list [--since 2026-06-01] [--limit 10] [--json] +``` + +Re-running `add` with the same title+url never appends a duplicate. + +## Tests + +```bash +cd scripts/trends +npm install +npm test # deterministic store: normalize/id, load/save, dedup+union, query, history +npm run build +``` diff --git a/scripts/trends/package-lock.json b/scripts/trends/package-lock.json new file mode 100644 index 0000000..f62a7fe --- /dev/null +++ b/scripts/trends/package-lock.json @@ -0,0 +1,566 @@ +{ + "name": "linkedin-trends-store", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkedin-trends-store", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/scripts/trends/package.json b/scripts/trends/package.json new file mode 100644 index 0000000..25a37b2 --- /dev/null +++ b/scripts/trends/package.json @@ -0,0 +1,16 @@ +{ + "name": "linkedin-trends-store", + "version": "0.1.0", + "type": "module", + "description": "Persistent trend store (research-engine §5, foundation layer) — a topic-tagged, provenance-bearing inventory of captured trend signals so the research engine accumulates history instead of starting amnesiac each session. Fully generic: nothing niche-specific lives here. Deterministic store + dedup + query; the capture/scoring lives in the agent/command layer.", + "scripts": { + "build": "tsc", + "test": "node --import tsx --test tests/*.test.ts", + "start": "node --import tsx src/cli.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/scripts/trends/src/cli.ts b/scripts/trends/src/cli.ts new file mode 100644 index 0000000..001e791 --- /dev/null +++ b/scripts/trends/src/cli.ts @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** + * CLI for the persistent trend store (research-engine §5, foundation layer). + * + * node --import tsx src/cli.ts add --title "" --url "" --topics + * [--source ] [--summary ""] [--store ] + * node --import tsx src/cli.ts query --topics [--store ] [--json] + * node --import tsx src/cli.ts list [--since ] [--limit ] [--store ] [--json] + * + * The capture agent (research-engine) calls `add` to fold a freshly-polled trend + * into the store, and `query`/`list` to reason over accumulated history. The + * polling + relevance-scoring itself lives upstream; this is the deterministic store. + * + * Exit code: 0 on success, 2 on usage error. + */ + +import { + addTrend, + defaultStorePath, + history, + loadStore, + queryByTopic, + saveStore, +} from "./store.js"; + +function parseFlags(args: string[]): Record { + const out: Record = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + out[key] = "true"; + } else { + out[key] = next; + i++; + } + } + } + return out; +} + +function splitTopics(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +function usage(msg: string): never { + console.error(`error: ${msg}`); + console.error( + "usage:\n" + + ' add --title "" --url "" --topics [--source ] [--summary ""] [--store ]\n' + + " query --topics [--store ] [--json]\n" + + " list [--since ] [--limit ] [--store ] [--json]", + ); + process.exit(2); +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function main(): void { + const [command, ...rest] = process.argv.slice(2); + const flags = parseFlags(rest); + const storePath = flags.store ?? defaultStorePath(); + const asJson = flags.json === "true"; + + if (command === "add") { + const title = flags.title; + if (!title || title === "true") usage('add needs --title ""'); + const url = flags.url; + if (!url || url === "true") usage('add needs --url ""'); + const topics = splitTopics(flags.topics); + if (topics.length === 0) usage("add needs --topics "); + const store = loadStore(storePath); + const res = addTrend(store, { + title, + url, + source: flags.source && flags.source !== "true" ? flags.source : "manual", + capturedAt: today(), + topics, + ...(flags.summary && flags.summary !== "true" ? { summary: flags.summary } : {}), + }); + saveStore(storePath, res.store); + if (res.added) { + console.log(`Added: ${title}`); + } else { + console.log(`Duplicate — already in store${res.merged ? " (topics unioned)" : ""}: ${title}`); + } + console.log(`Store: ${storePath} (${res.store.trends.length} trends)`); + return; + } + + if (command === "query") { + const topics = splitTopics(flags.topics); + if (topics.length === 0) usage("query needs --topics "); + const hits = queryByTopic(loadStore(storePath), topics); + if (asJson) { + console.log(JSON.stringify(hits, null, 2)); + return; + } + if (hits.length === 0) { + console.log(`No trends found for topics: ${topics.join(", ")}`); + console.log("→ poll fresh signals (research engine), then `add` them."); + return; + } + console.log(`${hits.length} trend(s) for: ${topics.join(", ")}`); + for (const { trend, topicOverlap } of hits) { + console.log(`\n · (overlap ${topicOverlap}) ${trend.title}`); + console.log(` ${trend.url}`); + console.log(` topics: ${trend.topics.join(", ")} — ${trend.source}, ${trend.capturedAt}`); + if (trend.summary) console.log(` ${trend.summary}`); + } + return; + } + + if (command === "list") { + const opts: { since?: string; limit?: number } = {}; + if (flags.since && flags.since !== "true") opts.since = flags.since; + if (flags.limit && flags.limit !== "true") { + const n = Number.parseInt(flags.limit, 10); + if (Number.isNaN(n) || n < 0) usage("--limit must be a non-negative integer"); + opts.limit = n; + } + const rows = history(loadStore(storePath), opts); + if (asJson) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + const scope = opts.since ? ` since ${opts.since}` : ""; + console.log(`Store: ${storePath} — ${rows.length} trend(s)${scope}`); + for (const t of rows) { + console.log(` · ${t.capturedAt} ${t.title} {${t.topics.join(", ")}}`); + } + return; + } + + usage(command ? `unknown command: ${command}` : "no command given"); +} + +main(); diff --git a/scripts/trends/src/store.ts b/scripts/trends/src/store.ts new file mode 100644 index 0000000..d80c070 --- /dev/null +++ b/scripts/trends/src/store.ts @@ -0,0 +1,172 @@ +/** + * Deterministic store + query for the persistent trend store (research-engine §5). + * + * Pure where it matters: id derivation, dedupe, topic query, and history are + * side-effect-free and fully testable. Only loadStore/saveStore touch the + * filesystem. No AI, no network — this module is reliable inventory, not a + * creatively-interpreted blob. The capture that POPULATES the store (polling + * research MCPs / web search, scoring relevance) lives in the agent/command + * layer; this module only stores, dedupes, and serves trend signals. + * + * Twin of scripts/specifics-bank/src/bank.ts — same discipline, different + * dedupe key (normalized title+URL instead of free-text content). + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; + +import { SCHEMA_VERSION } from "./types.js"; +import type { TrendStore, TrendRecord, TrendQueryHit } from "./types.js"; + +export { SCHEMA_VERSION } from "./types.js"; + +/** What a caller supplies to addTrend — the id is derived, never passed in. */ +export interface TrendInput { + title: string; + url: string; + source: string; + capturedAt: string; + topics: string[]; + summary?: string; +} + +export interface AddResult { + store: TrendStore; + /** true iff a new trend was appended (false = duplicate title+url). */ + added: boolean; + /** true iff an existing duplicate gained new topic tags via union. */ + merged: boolean; +} + +/** Options for a recency-ordered history slice. */ +export interface HistoryOptions { + /** Inclusive lower bound on capturedAt (ISO date); older trends are excluded. */ + since?: string; + /** Cap on the number of returned trends (newest kept). */ + limit?: number; +} + +/** Lowercase + trim + collapse all whitespace runs to a single space. */ +export function normalizeField(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +/** + * Stable id = first 12 hex of sha256(normalized title + "\n" + normalized url); + * also the dedupe key. The URL is folded to lowercase too: host case is + * insignificant, and the combined title+url key makes a false merge on a + * case-only path difference vanishingly unlikely. Richer URL canonicalization + * (trailing-slash / query-param stripping) is deferred to a later slice if + * dedup ever proves leaky. + */ +export function trendId(title: string, url: string): string { + const key = normalizeField(title) + "\n" + normalizeField(url); + return createHash("sha256").update(key).digest("hex").slice(0, 12); +} + +export function emptyStore(): TrendStore { + return { schemaVersion: SCHEMA_VERSION, trends: [] }; +} + +/** Read the store; a missing file is an empty store (never throws on absence). */ +export function loadStore(path: string): TrendStore { + if (!existsSync(path)) return emptyStore(); + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + return { + schemaVersion: parsed.schemaVersion ?? SCHEMA_VERSION, + trends: Array.isArray(parsed.trends) ? parsed.trends : [], + }; +} + +/** Write the store as pretty JSON, creating the parent dir if needed. */ +export function saveStore(path: string, store: TrendStore): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(store, null, 2) + "\n", "utf8"); +} + +/** Union of two tag lists, order-stable on the first list, case-insensitive dedupe. */ +function unionTopics(existing: string[], incoming: string[]): { topics: string[]; changed: boolean } { + const seen = new Set(existing.map((t) => t.toLowerCase())); + const topics = [...existing]; + let changed = false; + for (const t of incoming) { + if (!seen.has(t.toLowerCase())) { + seen.add(t.toLowerCase()); + topics.push(t); + changed = true; + } + } + return { topics, changed }; +} + +/** + * Add a trend, deduping on normalized title+url. A duplicate does not append a + * second entry — instead its topic tags are unioned in, so the same trend + * re-surfaced under a new edition's topics enriches the existing record. The + * FIRST sighting's source + capturedAt are kept (provenance of first sight); + * title/url/summary are stored VERBATIM, only the id is normalized. + */ +export function addTrend(store: TrendStore, input: TrendInput): AddResult { + const id = trendId(input.title, input.url); + const existing = store.trends.find((t) => t.id === id); + if (existing) { + const { topics, changed } = unionTopics(existing.topics, input.topics); + existing.topics = topics; + return { store, added: false, merged: changed }; + } + const trend: TrendRecord = { + id, + title: input.title, + url: input.url, + source: input.source, + capturedAt: input.capturedAt, + topics: [...input.topics], + ...(input.summary !== undefined ? { summary: input.summary } : {}), + }; + store.trends.push(trend); + return { store, added: true, merged: false }; +} + +/** + * Trends whose topics overlap the query, ranked by overlap (desc) then recency + * (capturedAt desc). Topic matching is case-insensitive. Non-matches are + * excluded. + */ +export function queryByTopic(store: TrendStore, topics: string[]): TrendQueryHit[] { + const wanted = topics.map((t) => t.toLowerCase()); + const hits: TrendQueryHit[] = []; + for (const trend of store.trends) { + const have = new Set(trend.topics.map((t) => t.toLowerCase())); + const topicOverlap = wanted.reduce((n, t) => (have.has(t) ? n + 1 : n), 0); + if (topicOverlap > 0) hits.push({ trend, topicOverlap }); + } + hits.sort( + (a, b) => b.topicOverlap - a.topicOverlap || b.trend.capturedAt.localeCompare(a.trend.capturedAt), + ); + return hits; +} + +/** + * Trend history, newest first. Optionally filtered to capturedAt >= `since` + * (inclusive) and capped to `limit`. The time-scoped complement to + * queryByTopic's topic-scoped view; together they are the "historikk-query". + */ +export function history(store: TrendStore, opts: HistoryOptions = {}): TrendRecord[] { + let out = [...store.trends]; + if (opts.since !== undefined) out = out.filter((t) => t.capturedAt >= opts.since!); + out.sort((a, b) => b.capturedAt.localeCompare(a.capturedAt)); + if (opts.limit !== undefined) out = out.slice(0, opts.limit); + return out; +} + +/** + * Default store path under the per-user data dir (M0 data-path convention), so + * the trend history survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` + * overrides the root; otherwise `~/.claude/linkedin-studio`. + */ +export function defaultStorePath(): string { + const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); + return join(root, "trends", "trends.json"); +} diff --git a/scripts/trends/src/types.ts b/scripts/trends/src/types.ts new file mode 100644 index 0000000..3427066 --- /dev/null +++ b/scripts/trends/src/types.ts @@ -0,0 +1,55 @@ +/** + * Types for the persistent trend store (research-engine §5, foundation layer). + * + * The store is the research engine's accumulating memory: a topic-tagged, + * provenance-bearing inventory of trend signals captured over time, so the + * engine reasons over HISTORY instead of starting amnesiac each session. It is + * the trend-side twin of the lived-specifics bank (scripts/specifics-bank): + * same deterministic store/dedup/query discipline, different dedupe key + * (normalized title+URL, since a trend is identified by its headline+source, + * not by free-text content). + * + * GENERIC BY ARCHITECTURE (retning §5 — nisje-agnostisk motor): nothing + * niche-specific lives in this module. A `TrendRecord` carries free-form + * `topics` tags and a free-form `source` string; which topics matter and which + * sources to poll are decided upstream (config/profile + the capture agent), + * never hard-coded here. The same store serves any niche. + * + * Forward-compatible with the profile-evolution / "second brain" architecture: + * a typed store in the per-user data dir (`${LINKEDIN_STUDIO_DATA}`), so the + * trend history survives plugin upgrades/reinstalls via the M0 data-path seam. + * The minimal core here (title, url, source, capturedAt, topics, optional + * summary) can gain fields (relevance score, first-mover timing, status) in a + * later slice without breaking the shape. + */ + +export interface TrendRecord { + /** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */ + id: string; + /** The trend headline, stored VERBATIM (normalized only to derive the id). */ + title: string; + /** The source URL, stored VERBATIM (normalized only to derive the id). */ + url: string; + /** Capture origin: a research-MCP name (e.g. "tavily"), "websearch", or "manual". */ + source: string; + /** ISO-8601 date the trend was captured. Supplied by the caller (CLI edge). */ + capturedAt: string; + /** Topic tags for query-by-topic. Unioned across re-captures of the same trend. */ + topics: string[]; + /** Optional short summary of the trend, stored VERBATIM. */ + summary?: string; +} + +export interface TrendStore { + schemaVersion: number; + trends: TrendRecord[]; +} + +/** A query hit: the trend plus how many of the queried topics it matched. */ +export interface TrendQueryHit { + trend: TrendRecord; + /** Number of queried topics present on the trend (≥1 for a hit). */ + topicOverlap: number; +} + +export const SCHEMA_VERSION = 1; diff --git a/scripts/trends/tests/store.test.ts b/scripts/trends/tests/store.test.ts new file mode 100644 index 0000000..80a0932 --- /dev/null +++ b/scripts/trends/tests/store.test.ts @@ -0,0 +1,297 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + normalizeField, + trendId, + emptyStore, + loadStore, + saveStore, + addTrend, + queryByTopic, + history, +} from "../src/store.js"; +import { SCHEMA_VERSION } from "../src/types.js"; +import type { TrendStore } from "../src/types.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "trends-store-")); + +describe("trends store", () => { + describe("normalizeField + trendId", () => { + test("normalizeField collapses whitespace + lowercases", () => { + assert.equal(normalizeField(" AI Trends\n2026 "), "ai trends 2026"); + }); + + test("id is deterministic and (title+url)-derived", () => { + assert.equal( + trendId("AI agents go mainstream", "https://example.com/a"), + trendId("AI agents go mainstream", "https://example.com/a"), + ); + }); + + test("id is stable across case + surrounding/collapsed whitespace in title AND url", () => { + const a = trendId("AI agents go mainstream", "https://example.com/article"); + const b = trendId(" ai AGENTS go mainstream ", " HTTPS://EXAMPLE.COM/article "); + assert.equal(a, b, "normalization should collapse case + whitespace on both fields"); + }); + + test("different title yields a different id (same url)", () => { + assert.notEqual( + trendId("AI agents go mainstream", "https://example.com/a"), + trendId("AI copilots go mainstream", "https://example.com/a"), + ); + }); + + test("different url yields a different id (same title)", () => { + assert.notEqual( + trendId("Same headline", "https://example.com/a"), + trendId("Same headline", "https://example.com/b"), + ); + }); + }); + + describe("emptyStore + load/save round-trip", () => { + test("emptyStore has the schema version and no trends", () => { + const s = emptyStore(); + assert.equal(s.schemaVersion, SCHEMA_VERSION); + assert.deepEqual(s.trends, []); + }); + + test("loadStore on a missing file returns an empty store (never throws)", () => { + const dir = tmp(); + try { + const s = loadStore(join(dir, "does-not-exist.json")); + assert.equal(s.schemaVersion, SCHEMA_VERSION); + assert.deepEqual(s.trends, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("saveStore creates the parent dir, then loadStore round-trips", () => { + const dir = tmp(); + const path = join(dir, "nested", "trends.json"); + try { + let store: TrendStore = emptyStore(); + store = addTrend(store, { + title: "Norway opens public-sector AI sandbox", + url: "https://example.com/sandbox", + source: "tavily", + capturedAt: "2026-06-20", + topics: ["public-sector", "ai-policy"], + summary: "A regulatory sandbox for public bodies.", + }).store; + saveStore(path, store); + assert.ok(existsSync(path)); + const loaded = loadStore(path); + assert.equal(loaded.trends.length, 1); + assert.equal(loaded.trends[0].title, "Norway opens public-sector AI sandbox"); + assert.equal(loaded.trends[0].source, "tavily"); + assert.equal(loaded.trends[0].summary, "A regulatory sandbox for public bodies."); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); + + describe("addTrend", () => { + test("adds a new trend with a computed id and reports added:true", () => { + const res = addTrend(emptyStore(), { + title: "Agentic workflows hit production", + url: "https://example.com/agentic", + source: "websearch", + capturedAt: "2026-06-20", + topics: ["agents", "engineering"], + }); + assert.equal(res.added, true); + assert.equal(res.merged, false); + assert.equal(res.store.trends.length, 1); + assert.equal(res.store.trends[0].id, trendId(res.store.trends[0].title, res.store.trends[0].url)); + }); + + test("preserves title/url/summary verbatim (normalizes only for the id)", () => { + const title = " Mixed CASE Headline "; + const url = " https://EXAMPLE.com/Path "; + const summary = " A summary with spacing. "; + const res = addTrend(emptyStore(), { + title, + url, + source: "manual", + capturedAt: "2026-06-20", + topics: ["x"], + summary, + }); + assert.equal(res.store.trends[0].title, title, "title must not be mutated"); + assert.equal(res.store.trends[0].url, url, "url must not be mutated"); + assert.equal(res.store.trends[0].summary, summary, "summary must not be mutated"); + }); + + test("omitting summary stores no summary key", () => { + const res = addTrend(emptyStore(), { + title: "No summary here", + url: "https://example.com/n", + source: "manual", + capturedAt: "2026-06-20", + topics: ["x"], + }); + assert.equal("summary" in res.store.trends[0], false); + }); + + test("dedupes on normalized title+url: same trend twice → one entry, topics unioned", () => { + let store = emptyStore(); + store = addTrend(store, { + title: "Reasoning models reshape RAG", + url: "https://example.com/rag", + source: "tavily", + capturedAt: "2026-06-19", + topics: ["rag"], + }).store; + const res2 = addTrend(store, { + title: " reasoning MODELS reshape rag ", + url: " HTTPS://EXAMPLE.COM/rag ", + source: "gemini", + capturedAt: "2026-06-20", + topics: ["search", "rag"], + }); + assert.equal(res2.added, false, "duplicate title+url must not add a second entry"); + assert.equal(res2.merged, true, "new topics on a duplicate must report merged:true"); + assert.equal(res2.store.trends.length, 1); + assert.deepEqual( + [...res2.store.trends[0].topics].sort(), + ["rag", "search"], + "topics must be unioned on re-capture", + ); + }); + + test("a duplicate keeps the first sighting's source + capturedAt (provenance of first sight)", () => { + let store = emptyStore(); + store = addTrend(store, { + title: "Same trend", + url: "https://example.com/s", + source: "first-source", + capturedAt: "2026-06-01", + topics: ["a"], + }).store; + store = addTrend(store, { + title: "Same trend", + url: "https://example.com/s", + source: "second-source", + capturedAt: "2026-06-20", + topics: ["b"], + }).store; + assert.equal(store.trends[0].source, "first-source"); + assert.equal(store.trends[0].capturedAt, "2026-06-01"); + }); + + test("a duplicate with no new topics reports merged:false", () => { + let store = emptyStore(); + store = addTrend(store, { + title: "Stable trend", + url: "https://example.com/st", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["a", "b"], + }).store; + const res2 = addTrend(store, { + title: "Stable trend", + url: "https://example.com/st", + source: "tavily", + capturedAt: "2026-06-02", + topics: ["b", "a"], + }); + assert.equal(res2.added, false); + assert.equal(res2.merged, false, "no new tags → merged:false"); + }); + }); + + describe("queryByTopic", () => { + const seed = (): TrendStore => { + let store = emptyStore(); + store = addTrend(store, { + title: "Trend A", + url: "https://example.com/a", + source: "tavily", + capturedAt: "2026-05-01", + topics: ["agents", "engineering"], + }).store; + store = addTrend(store, { + title: "Trend B", + url: "https://example.com/b", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["agents", "engineering", "public-sector"], + }).store; + store = addTrend(store, { + title: "Trend C", + url: "https://example.com/c", + source: "manual", + capturedAt: "2026-06-10", + topics: ["privacy"], + }).store; + return store; + }; + + test("ranks hits by topic overlap (desc)", () => { + // B carries both queried topics; A carries only "agents" → distinct overlaps. + const hits = queryByTopic(seed(), ["agents", "public-sector"]); + assert.equal(hits.length, 2); + assert.equal(hits[0].trend.title, "Trend B"); // overlap 2 + assert.equal(hits[0].topicOverlap, 2); + assert.equal(hits[1].trend.title, "Trend A"); // overlap 1 (agents only) + assert.equal(hits[1].topicOverlap, 1); + }); + + test("matching is case-insensitive on topics", () => { + const hits = queryByTopic(seed(), ["AGENTS"]); + assert.equal(hits.length, 2); + }); + + test("returns [] when nothing matches", () => { + assert.deepEqual(queryByTopic(seed(), ["nothing"]), []); + }); + + test("ties on overlap are broken by capturedAt (newest first)", () => { + const hits = queryByTopic(seed(), ["agents"]); // A and B both overlap 1 + assert.equal(hits[0].trend.title, "Trend B"); // 2026-06-01 newer than 2026-05-01 + assert.equal(hits[1].trend.title, "Trend A"); + }); + }); + + describe("history", () => { + const seed = (): TrendStore => { + let store = emptyStore(); + for (const [title, capturedAt] of [ + ["Oldest", "2026-04-01"], + ["Middle", "2026-05-01"], + ["Newest", "2026-06-01"], + ] as const) { + store = addTrend(store, { + title, + url: `https://example.com/${title}`, + source: "tavily", + capturedAt, + topics: ["x"], + }).store; + } + return store; + }; + + test("returns all trends newest-first", () => { + const h = history(seed()); + assert.deepEqual(h.map((t) => t.title), ["Newest", "Middle", "Oldest"]); + }); + + test("respects an inclusive `since` filter", () => { + const h = history(seed(), { since: "2026-05-01" }); + assert.deepEqual(h.map((t) => t.title), ["Newest", "Middle"]); + }); + + test("respects `limit`", () => { + const h = history(seed(), { limit: 1 }); + assert.deepEqual(h.map((t) => t.title), ["Newest"]); + }); + }); +}); diff --git a/scripts/trends/tsconfig.json b/scripts/trends/tsconfig.json new file mode 100644 index 0000000..eaa1e8e --- /dev/null +++ b/scripts/trends/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "tests"] +}