feat(linkedin-studio): specifics-bank store — lived-specifics inventory (fix #2 slice 1)
Fix #2 (kilde-så-draft) slice 1: the deterministic STORE half of the lived-specifics bank — the operator's real, un-generatable raw material (measured number / named-but-real case / what broke / contrarian opinion / mind-change), so drafts start grounded instead of hollow (retning §3; dream-spec "Lived-Specifics Extraction", the single biggest missing upstream piece for the authenticity thesis). scripts/specifics-bank/ (sibling to contract-gate, same tsx convention): - src/types.ts — Specific/Bank schema (schemaVersion 1), un-generatable taxonomy - src/bank.ts — pure store: normalizeContent, content-hash id (= dedupe key), load/save, addSpecific (dedupe + topicTag union on re-capture), queryByTopic (active-only, ranked by tag overlap then recency) - src/cli.ts — query / add / list; default bank under ${LINKEDIN_STUDIO_DATA:-~/.claude/linkedin-studio}/specifics-bank/ so it survives plugin upgrades/reinstalls (M0 data-path seam) - tests/bank.test.ts — 15/15 green; tsc clean - README + .gitignore for node_modules/build Content is stored verbatim; never AI-generated. The elicitation interview (refuses vague answers; abstrakt/ekstern escape hatches) lands in slice 3 (/linkedin:newsletter Step 1.5). Forward-compatible with the profile-evolution / "second brain" architecture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e26d9fb659
commit
b390b87ad5
9 changed files with 1239 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
65
scripts/specifics-bank/README.md
Normal file
65
scripts/specifics-bank/README.md
Normal file
|
|
@ -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 <path>` 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
|
||||
```
|
||||
566
scripts/specifics-bank/package-lock.json
generated
Normal file
566
scripts/specifics-bank/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
scripts/specifics-bank/package.json
Normal file
16
scripts/specifics-bank/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
153
scripts/specifics-bank/src/bank.ts
Normal file
153
scripts/specifics-bank/src/bank.ts
Normal file
|
|
@ -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<Bank>;
|
||||
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");
|
||||
}
|
||||
151
scripts/specifics-bank/src/cli.ts
Normal file
151
scripts/specifics-bank/src/cli.ts
Normal file
|
|
@ -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 <a,b> [--bank <path>] [--json]
|
||||
* node --import tsx src/cli.ts add --type <t> --content "<text>" --tags <a,b>
|
||||
* [--source <s>] [--verification <v>] [--bank <path>]
|
||||
* node --import tsx src/cli.ts list [--bank <path>] [--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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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 <a,b> [--bank <path>] [--json]\n" +
|
||||
' add --type <t> --content "<text>" --tags <a,b> [--source <s>] [--verification <v>] [--bank <path>]\n' +
|
||||
" list [--bank <path>] [--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 <a,b>");
|
||||
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 \"<text>\"");
|
||||
const tags = splitTags(flags.tags);
|
||||
if (tags.length === 0) usage("add needs --tags <a,b>");
|
||||
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();
|
||||
72
scripts/specifics-bank/src/types.ts
Normal file
72
scripts/specifics-bank/src/types.ts
Normal file
|
|
@ -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;
|
||||
198
scripts/specifics-bank/tests/bank.test.ts
Normal file
198
scripts/specifics-bank/tests/bank.test.ts
Normal file
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
16
scripts/specifics-bank/tsconfig.json
Normal file
16
scripts/specifics-bank/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue