diff --git a/.gitignore b/.gitignore index 945822b..556d04d 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,8 @@ scripts/analytics/node_modules/ scripts/analytics/build/ scripts/contract-gate/node_modules/ scripts/contract-gate/build/ +scripts/specifics-bank/node_modules/ +scripts/specifics-bank/build/ # --- session/local state --- # STATE.md is LOCAL-ONLY (gitignored): no private remote exists and STATE must diff --git a/scripts/specifics-bank/README.md b/scripts/specifics-bank/README.md new file mode 100644 index 0000000..0c06a23 --- /dev/null +++ b/scripts/specifics-bank/README.md @@ -0,0 +1,65 @@ +# specifics-bank — the lived-specifics inventory (Fix #2, kilde-så-draft) + +A deterministic, topic-tagged store of the operator's **real, un-generatable raw +material** — the specific measured number, the named (anonymized-but-real) case, the +thing that actually broke, the unfashionable opinion privately held, the moment they +changed their mind. The point (retning §3, dream-spec «Lived-Specifics Extraction»): +**sourcing, not styling, is where authenticity is won.** Drafts must draw from real +inventory instead of inventing plausible filler, so every downstream voice gate polishes +grounded content, not hollow content. + +This is the **store half** (deterministic + tested). The **elicitation half** — a guided +interview that refuses vague answers (escape hatches: mark a slot `abstrakt` or `ekstern`) +— lives in the command layer (`/linkedin:newsletter` Step 1.5, slice 3). The bank never +holds AI-generated lived experience; every `content` is the operator's own words. + +## Where the bank lives + +`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/specifics-bank/specifics-bank.json` + +It sits in the per-user data dir (M0 data-path convention), **outside** the plugin +install, so it **survives plugin upgrades and reinstalls**. It is the first concrete +tributary of the broader profile-evolution / "second brain" architecture. + +## Setup + +```bash +cd scripts/specifics-bank && npm install +``` + +## Use + +```bash +# Surface inventory the operator already has for an edition's topics +node --import tsx src/cli.ts query --tags "saksbehandling,produktivitet" + +# Fold newly-elicited material back into the bank +node --import tsx src/cli.ts add --type number \ + --content "Saksbehandleren sparte to dager i uka." \ + --tags "saksbehandling,produktivitet" --source "seres/05" + +# Inspect the whole bank +node --import tsx src/cli.ts list +``` + +Flags: `--bank ` overrides the default location; `--json` emits machine output for +`query`/`list`. `add` defaults `--verification` to `unverified` (numbers stay +guilty-until-checked — regel 6/7) and `--source` to `manual`. + +`--type` ∈ `number | named-case | what-broke | contrarian | mind-change | other`. + +## Model + +- **Dedupe key** = `sha256(normalizeContent(content)).slice(0,12)` (lowercase + trimmed + + whitespace-collapsed). Re-capturing the same material under a new edition's topic does + **not** add a second entry — its `topicTags` are unioned in, enriching the record. +- **Query** returns active specifics whose tags overlap, ranked by overlap (desc) then + recency (`capturedAt` desc); archived material is kept but never offered. +- **Content is stored verbatim**; only the id is normalized. + +## Test + +```bash +npm test # node:test, deterministic (callers supply capturedAt) +npm run build # tsc — must stay clean +``` diff --git a/scripts/specifics-bank/package-lock.json b/scripts/specifics-bank/package-lock.json new file mode 100644 index 0000000..0199d5c --- /dev/null +++ b/scripts/specifics-bank/package-lock.json @@ -0,0 +1,566 @@ +{ + "name": "linkedin-specifics-bank", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkedin-specifics-bank", + "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/specifics-bank/package.json b/scripts/specifics-bank/package.json new file mode 100644 index 0000000..fb72f07 --- /dev/null +++ b/scripts/specifics-bank/package.json @@ -0,0 +1,16 @@ +{ + "name": "linkedin-specifics-bank", + "version": "0.1.0", + "type": "module", + "description": "Lived-specifics bank (Fix #2, kilde-så-draft) — a topic-tagged, provenance-bearing store of the operator's real, un-generatable raw material, so drafts start grounded instead of hollow. Deterministic store + query; the elicitation interview lives in the 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/specifics-bank/src/bank.ts b/scripts/specifics-bank/src/bank.ts new file mode 100644 index 0000000..5163a34 --- /dev/null +++ b/scripts/specifics-bank/src/bank.ts @@ -0,0 +1,153 @@ +/** + * Deterministic store + query for the lived-specifics bank (Fix #2). + * + * Pure where it matters: id derivation, dedupe, and topic query are + * side-effect-free and fully testable. Only loadBank/saveBank touch the + * filesystem. No AI, no network — the bank is reliable inventory, not a + * creatively-interpreted blob. The elicitation that POPULATES the bank lives in + * the command layer (a guided interview that refuses vague answers); this module + * only stores, dedupes, and serves what the operator actually supplied. + */ + +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 { + Bank, + Provenance, + QueryHit, + Specific, + SpecificStatus, + SpecificType, + Verification, +} from "./types.js"; + +export { SCHEMA_VERSION } from "./types.js"; + +/** What a caller supplies to addSpecific — the id is derived, never passed in. */ +export interface SpecificInput { + type: SpecificType; + /** The operator's raw material, verbatim. */ + content: string; + topicTags: string[]; + provenance: Provenance; + /** Defaults to "unverified" (numbers stay guilty-until-checked — regel 6/7). */ + verification?: Verification; + /** Defaults to "active". */ + status?: SpecificStatus; +} + +export interface AddResult { + bank: Bank; + /** true iff a new specific was appended (false = duplicate content). */ + added: boolean; + /** true iff an existing duplicate gained new topic tags via union. */ + merged: boolean; +} + +/** Lowercase + trim + collapse all whitespace runs to a single space. */ +export function normalizeContent(content: string): string { + return content.trim().toLowerCase().replace(/\s+/g, " "); +} + +/** Stable id = first 12 hex of sha256(normalizeContent) — also the dedupe key. */ +export function specificId(content: string): string { + return createHash("sha256").update(normalizeContent(content)).digest("hex").slice(0, 12); +} + +export function emptyBank(): Bank { + return { schemaVersion: SCHEMA_VERSION, specifics: [] }; +} + +/** Read the bank; a missing file is an empty bank (never throws on absence). */ +export function loadBank(path: string): Bank { + if (!existsSync(path)) return emptyBank(); + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + return { + schemaVersion: parsed.schemaVersion ?? SCHEMA_VERSION, + specifics: Array.isArray(parsed.specifics) ? parsed.specifics : [], + }; +} + +/** Write the bank as pretty JSON, creating the parent dir if needed. */ +export function saveBank(path: string, bank: Bank): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(bank, null, 2) + "\n", "utf8"); +} + +/** Union of two tag lists, order-stable on the first list, case-insensitive dedupe. */ +function unionTags(existing: string[], incoming: string[]): { tags: string[]; changed: boolean } { + const seen = new Set(existing.map((t) => t.toLowerCase())); + const tags = [...existing]; + let changed = false; + for (const t of incoming) { + if (!seen.has(t.toLowerCase())) { + seen.add(t.toLowerCase()); + tags.push(t); + changed = true; + } + } + return { tags, changed }; +} + +/** + * Add a specific, deduping on normalized content. A duplicate does not append a + * second entry — instead its topic tags are unioned in, so the same real + * material re-surfaced under a new edition's topic enriches the existing record. + * The operator's `content` is stored VERBATIM; only the id is normalized. + */ +export function addSpecific(bank: Bank, input: SpecificInput): AddResult { + const id = specificId(input.content); + const existing = bank.specifics.find((s) => s.id === id); + if (existing) { + const { tags, changed } = unionTags(existing.topicTags, input.topicTags); + existing.topicTags = tags; + return { bank, added: false, merged: changed }; + } + const specific: Specific = { + id, + type: input.type, + content: input.content, + topicTags: [...input.topicTags], + provenance: input.provenance, + verification: input.verification ?? "unverified", + status: input.status ?? "active", + }; + bank.specifics.push(specific); + return { bank, added: true, merged: false }; +} + +/** + * Active specifics whose tags overlap the query, ranked by overlap (desc) then + * recency (capturedAt desc). Tag matching is case-insensitive. Non-matches and + * archived material are excluded. + */ +export function queryByTopic(bank: Bank, tags: string[]): QueryHit[] { + const wanted = tags.map((t) => t.toLowerCase()); + const hits: QueryHit[] = []; + for (const specific of bank.specifics) { + if (specific.status !== "active") continue; + const have = new Set(specific.topicTags.map((t) => t.toLowerCase())); + const tagOverlap = wanted.reduce((n, t) => (have.has(t) ? n + 1 : n), 0); + if (tagOverlap > 0) hits.push({ specific, tagOverlap }); + } + hits.sort( + (a, b) => + b.tagOverlap - a.tagOverlap || + b.specific.provenance.capturedAt.localeCompare(a.specific.provenance.capturedAt), + ); + return hits; +} + +/** + * Default bank path under the per-user data dir (M0 data-path convention), so the + * bank survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` overrides the + * root; otherwise `~/.claude/linkedin-studio`. + */ +export function defaultBankPath(): string { + const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); + return join(root, "specifics-bank", "specifics-bank.json"); +} diff --git a/scripts/specifics-bank/src/cli.ts b/scripts/specifics-bank/src/cli.ts new file mode 100644 index 0000000..c715a69 --- /dev/null +++ b/scripts/specifics-bank/src/cli.ts @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * CLI for the lived-specifics bank (Fix #2 — kilde-så-draft). + * + * node --import tsx src/cli.ts query --tags [--bank ] [--json] + * node --import tsx src/cli.ts add --type --content "" --tags + * [--source ] [--verification ] [--bank ] + * node --import tsx src/cli.ts list [--bank ] [--json] + * + * The command layer (Step 1.5) calls `query` to surface inventory the operator + * already has for an edition's topics, and `add` to fold newly-elicited material + * back in. Deterministic store; the elicitation interview itself lives upstream. + * + * Exit code: 0 on success, 2 on usage error. + */ + +import { + addSpecific, + defaultBankPath, + loadBank, + queryByTopic, + saveBank, +} from "./bank.js"; +import type { SpecificType, Verification } from "./types.js"; + +const VALID_TYPES: SpecificType[] = [ + "number", + "named-case", + "what-broke", + "contrarian", + "mind-change", + "other", +]; +const VALID_VERIFICATION: Verification[] = ["verified", "unverified", "n/a"]; + +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 splitTags(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" + + " query --tags [--bank ] [--json]\n" + + ' add --type --content "" --tags [--source ] [--verification ] [--bank ]\n' + + " list [--bank ] [--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 bankPath = flags.bank ?? defaultBankPath(); + const asJson = flags.json === "true"; + + if (command === "query") { + const tags = splitTags(flags.tags); + if (tags.length === 0) usage("query needs --tags "); + const hits = queryByTopic(loadBank(bankPath), tags); + if (asJson) { + console.log(JSON.stringify(hits, null, 2)); + return; + } + if (hits.length === 0) { + console.log(`No lived specifics found for tags: ${tags.join(", ")}`); + console.log("→ elicit fresh material from the operator (Step 1.5), then `add` it."); + return; + } + console.log(`${hits.length} lived specific(s) for: ${tags.join(", ")}`); + for (const { specific, tagOverlap } of hits) { + const v = specific.verification === "verified" ? "" : ` [${specific.verification}]`; + console.log(`\n · (${specific.type}, overlap ${tagOverlap})${v} ${specific.content}`); + console.log(` tags: ${specific.topicTags.join(", ")} — from ${specific.provenance.source}`); + } + return; + } + + if (command === "add") { + const type = flags.type as SpecificType; + if (!VALID_TYPES.includes(type)) usage(`--type must be one of: ${VALID_TYPES.join(", ")}`); + const content = flags.content; + if (!content || content === "true") usage("add needs --content \"\""); + const tags = splitTags(flags.tags); + if (tags.length === 0) usage("add needs --tags "); + const verification = (flags.verification as Verification) ?? "unverified"; + if (!VALID_VERIFICATION.includes(verification)) { + usage(`--verification must be one of: ${VALID_VERIFICATION.join(", ")}`); + } + const bank = loadBank(bankPath); + const res = addSpecific(bank, { + type, + content, + topicTags: tags, + provenance: { capturedAt: today(), source: flags.source ?? "manual" }, + verification, + }); + saveBank(bankPath, res.bank); + if (res.added) { + console.log(`Added (${type}): ${content}`); + } else { + console.log(`Duplicate — already in bank${res.merged ? " (tags unioned)" : ""}: ${content}`); + } + console.log(`Bank: ${bankPath} (${res.bank.specifics.length} specifics)`); + return; + } + + if (command === "list") { + const bank = loadBank(bankPath); + if (asJson) { + console.log(JSON.stringify(bank, null, 2)); + return; + } + console.log(`Bank: ${bankPath} — ${bank.specifics.length} specific(s)`); + for (const s of bank.specifics) { + const flag = s.status === "active" ? "" : ` (${s.status})`; + console.log(` · [${s.type}]${flag} ${s.content} {${s.topicTags.join(", ")}}`); + } + return; + } + + usage(command ? `unknown command: ${command}` : "no command given"); +} + +main(); diff --git a/scripts/specifics-bank/src/types.ts b/scripts/specifics-bank/src/types.ts new file mode 100644 index 0000000..15a5f40 --- /dev/null +++ b/scripts/specifics-bank/src/types.ts @@ -0,0 +1,72 @@ +/** + * Types for the lived-specifics bank (Fix #2 — kilde-så-draft). + * + * The bank is the operator's inventory of REAL, un-generatable raw material: + * the specific measured number, the named (anonymized-but-real) case, the thing + * that actually broke, the unfashionable opinion privately held, the moment they + * changed their mind. The taxonomy mirrors the dream-spec's "Lived-Specifics + * Extraction" engine (docs/expert-review/dream-spec.local.md): sourcing, not + * styling, is where authenticity is won — so drafts must draw from real + * inventory instead of inventing plausible filler. + * + * INVARIANT (retning §3 — «ditt råmateriale, verktøyets håndverk»): every + * `content` is the OPERATOR's own words, captured through elicitation. The bank + * never holds AI-generated lived experience. The store half lives here + * (deterministic, testable); the elicitation interview lives in the command. + * + * Forward-compatible with the profile-evolution / "second brain" architecture: + * a typed, tag- and provenance-bearing knowledge store in the per-user data dir + * (`${LINKEDIN_STUDIO_DATA}`), so it survives plugin upgrades/reinstalls via the + * existing M0 data-path seam. New `SpecificType`s or cross-references can be + * added without breaking the shape. + */ + +/** The un-generatable taxonomy (dream-spec line 56). */ +export type SpecificType = + | "number" // a real, measured number (must be fact-checked — regel 6/7) + | "named-case" // a named (anonymized-but-real) client / project / person + | "what-broke" // the thing that actually went wrong / broke + | "contrarian" // an unfashionable opinion the operator privately holds + | "mind-change" // a moment the operator changed their mind + | "other"; // real material that does not fit the above + +/** Whether a checkable claim (esp. a number) has been verified to a source. */ +export type Verification = "verified" | "unverified" | "n/a"; + +/** Active material is offered by query; archived material is kept but not offered. */ +export type SpecificStatus = "active" | "archived"; + +export interface Provenance { + /** ISO-8601 date the material was captured. Supplied by the caller (CLI edge). */ + capturedAt: string; + /** Where it was captured: an edition id (e.g. "seres/05"), a command, or "manual". */ + source: string; +} + +export interface Specific { + /** Stable id — a short hash of the normalized content; doubles as the dedupe key. */ + id: string; + type: SpecificType; + /** The operator's raw material, in their words. NEVER AI-generated. */ + content: string; + /** Topic tags for query-by-topic. Unioned across re-captures of the same content. */ + topicTags: string[]; + provenance: Provenance; + /** Numbers must be fact-checked before a draft asserts them as fact (regel 6/7). */ + verification: Verification; + status: SpecificStatus; +} + +export interface Bank { + schemaVersion: number; + specifics: Specific[]; +} + +/** A query hit: the specific plus how many of the queried tags it matched. */ +export interface QueryHit { + specific: Specific; + /** Number of queried tags present on the specific (≥1 for a hit). */ + tagOverlap: number; +} + +export const SCHEMA_VERSION = 1; diff --git a/scripts/specifics-bank/tests/bank.test.ts b/scripts/specifics-bank/tests/bank.test.ts new file mode 100644 index 0000000..deab502 --- /dev/null +++ b/scripts/specifics-bank/tests/bank.test.ts @@ -0,0 +1,198 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + normalizeContent, + specificId, + emptyBank, + loadBank, + saveBank, + addSpecific, + queryByTopic, +} from "../src/bank.js"; +import { SCHEMA_VERSION } from "../src/types.js"; +import type { Bank } from "../src/types.js"; + +const prov = (source = "test", capturedAt = "2026-06-20") => ({ source, capturedAt }); + +const tmp = () => mkdtempSync(join(tmpdir(), "specifics-bank-")); + +describe("specifics-bank store", () => { + describe("normalizeContent + specificId", () => { + test("id is deterministic and content-derived", () => { + assert.equal(specificId("Sparte to dager."), specificId("Sparte to dager.")); + }); + + test("id is stable across case + surrounding/collapsed whitespace", () => { + const a = specificId("Sparte to dager."); + const b = specificId(" sparte TO dager. "); + assert.equal(a, b, "normalization should collapse case + whitespace"); + }); + + test("different content yields different ids", () => { + assert.notEqual(specificId("Sparte to dager."), specificId("Sparte tre dager.")); + }); + + test("normalizeContent collapses whitespace + lowercases", () => { + assert.equal(normalizeContent(" Hei DU\n da "), "hei du da"); + }); + }); + + describe("emptyBank + load/save round-trip", () => { + test("emptyBank has the schema version and no specifics", () => { + const b = emptyBank(); + assert.equal(b.schemaVersion, SCHEMA_VERSION); + assert.deepEqual(b.specifics, []); + }); + + test("loadBank on a missing file returns an empty bank (never throws)", () => { + const dir = tmp(); + try { + const b = loadBank(join(dir, "does-not-exist.json")); + assert.equal(b.schemaVersion, SCHEMA_VERSION); + assert.deepEqual(b.specifics, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("saveBank then loadBank round-trips", () => { + const dir = tmp(); + const path = join(dir, "bank.json"); + try { + let bank: Bank = emptyBank(); + bank = addSpecific(bank, { + type: "number", + content: "Saksbehandleren sparte to dager i uka.", + topicTags: ["saksbehandling", "produktivitet"], + provenance: prov("seres/05"), + verification: "unverified", + }).bank; + saveBank(path, bank); + assert.ok(existsSync(path)); + const loaded = loadBank(path); + assert.equal(loaded.specifics.length, 1); + assert.equal(loaded.specifics[0].content, "Saksbehandleren sparte to dager i uka."); + assert.equal(loaded.specifics[0].type, "number"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); + + describe("addSpecific", () => { + test("adds a new specific with a computed id and reports added:true", () => { + const res = addSpecific(emptyBank(), { + type: "named-case", + content: "En kontraktsjurist hos en kunde automatiserte avtalegjennomgang.", + topicTags: ["juss", "automatisering"], + provenance: prov(), + verification: "n/a", + }); + assert.equal(res.added, true); + assert.equal(res.bank.specifics.length, 1); + assert.equal(res.bank.specifics[0].id, specificId(res.bank.specifics[0].content)); + assert.equal(res.bank.specifics[0].status, "active"); + }); + + test("preserves the operator's content verbatim (normalizes only for id)", () => { + const content = " KI-en nådde IKKE arkivdataene. "; + const res = addSpecific(emptyBank(), { + type: "what-broke", + content, + topicTags: ["data"], + provenance: prov(), + }); + assert.equal(res.bank.specifics[0].content, content, "content must not be mutated"); + }); + + test("dedupes on normalized content: same material twice → one entry, tags unioned", () => { + let bank = emptyBank(); + bank = addSpecific(bank, { + type: "number", + content: "Sparte to dager.", + topicTags: ["produktivitet"], + provenance: prov("seres/04"), + }).bank; + const res2 = addSpecific(bank, { + type: "number", + content: " sparte to dager. ", + topicTags: ["saksbehandling"], + provenance: prov("seres/05"), + }); + assert.equal(res2.added, false, "duplicate content must not add a second entry"); + assert.equal(res2.bank.specifics.length, 1); + assert.deepEqual( + [...res2.bank.specifics[0].topicTags].sort(), + ["produktivitet", "saksbehandling"], + "tags must be unioned on re-capture", + ); + }); + + test("defaults verification to unverified and status to active when omitted", () => { + const res = addSpecific(emptyBank(), { + type: "contrarian", + content: "De fleste KI-pilotene feiler på data, ikke på modellvalg.", + topicTags: ["data"], + provenance: prov(), + }); + assert.equal(res.bank.specifics[0].verification, "unverified"); + assert.equal(res.bank.specifics[0].status, "active"); + }); + }); + + describe("queryByTopic", () => { + const seed = (): Bank => { + let bank = emptyBank(); + bank = addSpecific(bank, { + type: "named-case", + content: "Saksbehandler-case A.", + topicTags: ["saksbehandling", "arkiv"], + provenance: prov("seres/04", "2026-05-01"), + }).bank; + bank = addSpecific(bank, { + type: "number", + content: "Tallet B.", + topicTags: ["saksbehandling", "produktivitet", "ledelse"], + provenance: prov("seres/05", "2026-06-01"), + }).bank; + bank = addSpecific(bank, { + type: "other", + content: "Irrelevant C.", + topicTags: ["privatliv"], + provenance: prov("manual", "2026-06-10"), + }).bank; + return bank; + }; + + test("ranks hits by tag overlap (desc)", () => { + const hits = queryByTopic(seed(), ["saksbehandling", "produktivitet"]); + assert.equal(hits.length, 2); + assert.equal(hits[0].specific.content, "Tallet B."); // overlap 2 + assert.equal(hits[0].tagOverlap, 2); + assert.equal(hits[1].specific.content, "Saksbehandler-case A."); // overlap 1 + assert.equal(hits[1].tagOverlap, 1); + }); + + test("returns [] when nothing matches", () => { + assert.deepEqual(queryByTopic(seed(), ["ingenting"]), []); + }); + + test("excludes archived specifics", () => { + const bank = seed(); + bank.specifics[1].status = "archived"; // archive "Tallet B." + const hits = queryByTopic(bank, ["saksbehandling"]); + assert.equal(hits.length, 1); + assert.equal(hits[0].specific.content, "Saksbehandler-case A."); + }); + + test("ties on overlap are broken by capturedAt (newest first)", () => { + const hits = queryByTopic(seed(), ["saksbehandling"]); // both A and B overlap 1 + assert.equal(hits[0].specific.content, "Tallet B."); // 2026-06-01 newer than 2026-05-01 + assert.equal(hits[1].specific.content, "Saksbehandler-case A."); + }); + }); +}); diff --git a/scripts/specifics-bank/tsconfig.json b/scripts/specifics-bank/tsconfig.json new file mode 100644 index 0000000..eaa1e8e --- /dev/null +++ b/scripts/specifics-bank/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"] +}