From 8c927198f55de2192707712467c57bbd4fe60733 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 14:05:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20second-brain=20SB-S0?= =?UTF-8?q?=20foundation=20=E2=80=94=20brain/=20scaffold=20+=20profile=20f?= =?UTF-8?q?old=20+=20id/provenance=20spine=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SB-S0 (Foundation) of the second-brain arc: a new TS package scripts/brain/ (structural copy of specifics-bank) establishing the spine later slices hang on. TDD throughout (failing test first); fold scope = P1+P2 only per operator decision. - types.ts: one provenance vocab (human|published|ai-draft) + the six-field ProfileFact record + two-layer ProfileDoc. - id.ts: slugify + mintEntityId (sha256[:12], kind-namespaced, slug-keyed so the id is stable across value edits) + normalizeProvenance (throws on unknown). [SC4] - profile.ts: no-YAML line-grammar parse/serialize (parse∘serialize = identity over the whole doc; values may contain ]/|/quotes) [SC2] + foldUserProfile: lossless, idempotent, source-absent-aware fold of config/user-profile.template.md. Pinned extraction — P1 labeled scalars (group-headers skipped, [placeholder]→empty) + P2 expertise group; stops at "### Research Tooling" so deferred explainer prose can't leak in as fields. Checkbox-prefs (Goals/Tone/MCPs/Assets) deferred (§8). [SC3] - dataRoot.ts: inline per-package resolver (repo idiom; no new seam → SC6). - scaffold.ts/cli.ts: idempotent `brain init` — brain/{index,profile,operations}.md + journal/ + ingest/{inbox,published} under the data-dir; compare-then-skip, never clobbers a user edit. No session-start wiring (SB-S2 owns it). [SC1] Gate: new BRAIN floor in test-runner.sh (34 tests; trends/specifics/contract floors unchanged) + anti-erosion floor 74→75. Full gate 90/0/0, tsc --noEmit clean. [SC5/SC6] No new command/agent/reference → no version bump, no structure-count change. Refs docs/second-brain/{brief,plan-sb-s0,architecture}.md. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4 --- .gitignore | 2 + docs/second-brain/architecture.md | 4 +- .../brain/fixtures/user-profile.populated.md | 59 ++ scripts/brain/package-lock.json | 566 ++++++++++++++++++ scripts/brain/package.json | 16 + scripts/brain/src/cli.ts | 45 ++ scripts/brain/src/dataRoot.ts | 19 + scripts/brain/src/id.ts | 54 ++ scripts/brain/src/profile.ts | 224 +++++++ scripts/brain/src/scaffold.ts | 117 ++++ scripts/brain/src/types.ts | 55 ++ scripts/brain/tests/fold.test.ts | 113 ++++ scripts/brain/tests/id.test.ts | 73 +++ scripts/brain/tests/profile.test.ts | 80 +++ scripts/brain/tests/scaffold.test.ts | 78 +++ scripts/brain/tsconfig.json | 16 + scripts/test-runner.sh | 41 +- 17 files changed, 1554 insertions(+), 8 deletions(-) create mode 100644 scripts/brain/fixtures/user-profile.populated.md create mode 100644 scripts/brain/package-lock.json create mode 100644 scripts/brain/package.json create mode 100644 scripts/brain/src/cli.ts create mode 100644 scripts/brain/src/dataRoot.ts create mode 100644 scripts/brain/src/id.ts create mode 100644 scripts/brain/src/profile.ts create mode 100644 scripts/brain/src/scaffold.ts create mode 100644 scripts/brain/src/types.ts create mode 100644 scripts/brain/tests/fold.test.ts create mode 100644 scripts/brain/tests/id.test.ts create mode 100644 scripts/brain/tests/profile.test.ts create mode 100644 scripts/brain/tests/scaffold.test.ts create mode 100644 scripts/brain/tsconfig.json diff --git a/.gitignore b/.gitignore index 2a90a96..53ec54a 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,8 @@ docs/**/.session-state.local.json # Node.js scripts/analytics/node_modules/ scripts/analytics/build/ +scripts/brain/node_modules/ +scripts/brain/build/ scripts/contract-gate/node_modules/ scripts/contract-gate/build/ scripts/specifics-bank/node_modules/ diff --git a/docs/second-brain/architecture.md b/docs/second-brain/architecture.md index 3768036..089e7b6 100644 --- a/docs/second-brain/architecture.md +++ b/docs/second-brain/architecture.md @@ -1,6 +1,6 @@ # Second Brain — Architecture Design -> **Status:** architecture **approved by operator 2026-06-23**. Design phase — no code yet. +> **Status:** architecture **approved by operator 2026-06-23**. **SB-S0 (Foundation) landed 2026-06-23** (`scripts/brain/`, 34 tests, gate-wired); S1–S4 remain design-phase. > **Boundary (confirmed 2026-06-23):** the **engine** (store schema · evolution loop · ingest seam) → **the plugin** (domain-general, shareable); the **user's data** (posts · articles · newsletters · plans · ideas) → the **per-user data dir** (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/`, survives reinstall); the **personal cockpit** (the operator's day-to-day operations centre) → **Maskinrommet** (a thin layer that reads/writes *through* the plugin's store, never a fork of the engine). > **Research inputs (three parallel threads, 2026-06-23):** `research/connector-egress.md` · `research/secondbrain-sota.md` · `research/silo-inventory.md`. @@ -74,7 +74,7 @@ Run on a cadence (reuse the existing session-start staleness-nudge surface) as a | Slice | Content | Why this order | |-------|---------|----------------| -| **SB-S0 — Foundation** | `brain/` scaffold + two-layer `profile.md` (fold in `user-profile.md`) + `index.md` MOC + `operations.md` + `journal/`; entity-id + provenance shape as a small typed, tested module; `ingest/` dirs + manual-import contract. **No loop yet.** | Smallest thing that stands up and is testable; locks the id/provenance spine everything hangs on | +| **SB-S0 — Foundation** ✅ *landed 2026-06-23* | `brain/` scaffold + two-layer `profile.md` (fold in `user-profile.md`) + `index.md` MOC + `operations.md` + `journal/`; entity-id + provenance shape as a small typed, tested module; `ingest/` dirs + manual-import contract. **No loop yet.** Shipped as `scripts/brain/` (TS, 34 tests, gate-wired BRAIN floor); fold = P1 labeled-scalars + P2 expertise (checkbox-prefs deferred, §8 of `plan-sb-s0.md`). | Smallest thing that stands up and is testable; locks the id/provenance spine everything hangs on | | **SB-S1 — Ingest + gold signal** | Manual import → `ingest/published/` with `provenance=published`; wire profile/voice to learn from published-only | The gold signal before the loop that consumes it | | **SB-S2 — Evolution loop** | Sleep-time consolidation (reuse session-start surface): journal+published+tributary deltas → profile **diff** w/ evidence_count/provenance/timestamps; threshold promotion; contradiction reconciliation; brain-wide freshness nudge | The compounding mechanism | | **SB-S3 — Cross-silo graph + ops centre** | Thread the id through tributaries (post↔specific↔trend↔analytics assemblable); flesh out `operations.md`; retire the dead `content-history.md` + triple-post reconciliation | Finally answers "which raw material performs?" | diff --git a/scripts/brain/fixtures/user-profile.populated.md b/scripts/brain/fixtures/user-profile.populated.md new file mode 100644 index 0000000..cca62f1 --- /dev/null +++ b/scripts/brain/fixtures/user-profile.populated.md @@ -0,0 +1,59 @@ +# User Profile Configuration (populated test fixture) + +> Synthetic, domain-general fixture for SB-S0 fold tests. Mirrors the P1 labeled +> scalars + P2 expertise group of `config/user-profile.template.md`, with every +> placeholder replaced by a distinctive filled value. NOT a real person. + +--- + +## PERSONALIZATION SETTINGS + +### User Profile Context + +**Name:** Jordan Avery +**Current Role:** Senior Data Engineer +**Organization:** Northwind Analytics +**Industry/Domain:** Data infrastructure + +**Important Disclaimer:** All articles and posts are written as a private individual. Views expressed are personal and do not represent any employer. + +**Core Expertise Areas (5 topics):** +1. Streaming data pipelines +2. Data contracts +3. Cost-aware warehousing +4. Observability for ETL +5. Team data literacy + +**Target Audience:** +- **Primary:** Data engineers at scale-ups +- **Secondary:** Engineering managers +- **Geographic focus:** Nordics + +--- + +### Voice & Style Profile + +**Signature Elements:** +- **Key frameworks you've developed:** The Contract-First Pipeline +- **Recurring themes/angles:** Boring tech that ships +- **Phrases you commonly use:** "ship the boring thing" +- **Topics to AVOID:** Crypto + +**Writing Quirks & Preferences:** +- **Preferred post length:** Medium 1,200-1,800 +- **Emoji usage:** Minimal 1-2 +- **Question style CTAs:** Sometimes +- **Use of personal anecdotes:** Always +- **Technical depth:** Advanced + +**Language:** English + +--- + +### Strategic Context + +**Current LinkedIn Status:** +- **Follower count:** 2,400 +- **90-day growth goal:** 4,000 +- **Posting frequency:** 3x week +- **Optimal posting times:** Tue/Thu 08:00 CET diff --git a/scripts/brain/package-lock.json b/scripts/brain/package-lock.json new file mode 100644 index 0000000..f292c9b --- /dev/null +++ b/scripts/brain/package-lock.json @@ -0,0 +1,566 @@ +{ + "name": "linkedin-brain", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkedin-brain", + "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/brain/package.json b/scripts/brain/package.json new file mode 100644 index 0000000..c7ddd8c --- /dev/null +++ b/scripts/brain/package.json @@ -0,0 +1,16 @@ +{ + "name": "linkedin-brain", + "version": "0.1.0", + "type": "module", + "description": "Second-brain foundation (SB-S0) — the brain/ scaffold initialiser, the two-layer profile.md line-grammar (parse/serialize round-trip), the lossless+idempotent user-profile fold, and the canonical entity-id + provenance module. Deterministic, pure-where-it-matters, TDD. No loop, no ingest logic, no connector (later slices).", + "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/brain/src/cli.ts b/scripts/brain/src/cli.ts new file mode 100644 index 0000000..ae2442a --- /dev/null +++ b/scripts/brain/src/cli.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * CLI for the second-brain foundation (SB-S0). + * + * node --import tsx src/cli.ts init + * + * `init` scaffolds the `brain/` + `ingest/` tree under the per-user data dir + * (`LINKEDIN_STUDIO_DATA` overrides the root). Idempotent — running it twice is a + * no-op. No session-start wiring yet (SB-S2 owns that); this is the invokable + * subcommand only. + * + * Exit code: 0 on success, 2 on usage error. + */ + +import { dataRoot } from "./dataRoot.js"; +import { initBrain } from "./scaffold.js"; + +function usage(msg: string): never { + console.error(`error: ${msg}`); + console.error("usage:\n init"); + process.exit(2); +} + +function main(): void { + const [command] = process.argv.slice(2); + + if (command === "init") { + const { created, skipped } = initBrain(); + console.log(`Brain root: ${dataRoot("brain")}`); + if (created.length > 0) { + console.log(`Created (${created.length}):`); + for (const c of created) console.log(` + ${c}`); + } + if (skipped.length > 0) { + console.log(`Skipped — already present (${skipped.length}):`); + for (const s of skipped) console.log(` · ${s}`); + } + if (created.length === 0) console.log("Already initialised — nothing to do."); + return; + } + + usage(command ? `unknown command: ${command}` : "no command given"); +} + +main(); diff --git a/scripts/brain/src/dataRoot.ts b/scripts/brain/src/dataRoot.ts new file mode 100644 index 0000000..2438ba0 --- /dev/null +++ b/scripts/brain/src/dataRoot.ts @@ -0,0 +1,19 @@ +/** + * Per-package data-root resolver (M3: the repo idiom, NOT a new shared seam). + * + * The trends/specifics/analytics packages each inline this same resolver + * (`trends/src/store.ts:180`, `specifics-bank/src/bank.ts:150`, + * `analytics/src/utils/storage.ts:54`) rather than importing a shared util — + * there is no cross-package path map. SB-S0 matches that idiom, so it adds NO new + * seam function and the `data-root.mjs ⇄ storage.ts` twin-sync stays untouched + * (SC6). De-duplicating the now-four copies is an explicit out-of-scope refactor. + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** Resolve `sub` under the per-user data dir; `LINKEDIN_STUDIO_DATA` overrides the root. */ +export function dataRoot(sub: string): string { + const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); + return join(root, sub); +} diff --git a/scripts/brain/src/id.ts b/scripts/brain/src/id.ts new file mode 100644 index 0000000..bc0b4ab --- /dev/null +++ b/scripts/brain/src/id.ts @@ -0,0 +1,54 @@ +/** + * Canonical entity-id + provenance normalization (SB-S0 spine). + * + * Pure + deterministic — no filesystem, no clock, no network. The id is keyed on + * a STABLE SLUG of the label, not the raw label, so editing a fact's *value* (or + * the label's case/whitespace) never re-mints the id. SB-S3 will thread this id + * through the tributaries; SB-S0 only establishes mint + shape. + */ + +import { createHash } from "node:crypto"; + +import { PROVENANCE_VALUES } from "./types.js"; +import type { Provenance } from "./types.js"; + +/** + * Stable slug: lowercase, trim, every non-alphanumeric run → a single `-`, with + * leading/trailing dashes stripped. Stable across case + whitespace variation of + * the same label, so it is a safe id key. + */ +export function slugify(label: string): string { + return label + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * One canonical entity id = first 12 hex of `sha256(`${kind}:${slugify(key)}`)`. + * Matches the trends/specifics `sha256[:12]` idiom (`specifics-bank/src/bank.ts:58`). + * `kind` namespaces the id so the same key under different kinds never collides. + */ +export function mintEntityId(seed: { kind: string; key: string }): string { + return createHash("sha256") + .update(`${seed.kind}:${slugify(seed.key)}`) + .digest("hex") + .slice(0, 12); +} + +/** + * Normalize a raw provenance string to the single brain vocabulary. Trims + + * lowercases, then returns the match or THROWS — provenance is load-bearing for + * the model-collapse guard, so an unknown value is an error, never a silent pass. + */ +export function normalizeProvenance(raw: string): Provenance { + const candidate = raw.trim().toLowerCase(); + const match = PROVENANCE_VALUES.find((v) => v === candidate); + if (!match) { + throw new Error( + `invalid provenance: ${JSON.stringify(raw)} — expected one of ${PROVENANCE_VALUES.join(", ")}`, + ); + } + return match; +} diff --git a/scripts/brain/src/profile.ts b/scripts/brain/src/profile.ts new file mode 100644 index 0000000..0cbbf4f --- /dev/null +++ b/scripts/brain/src/profile.ts @@ -0,0 +1,224 @@ +/** + * The two-layer `brain/profile.md` line-grammar + the user-profile fold (SB-S0). + * + * NO YAML dependency (M2): the repo has no YAML parser, so the profile uses a + * defined line-grammar that round-trips with the regex idiom already in-tree + * (`state-updater.mjs` scalar regex). One fact = one line: + * + * - [|||||] + * + * The six bracket tokens are all CONSTRAINED (two enums, two ISO dates, an int, a + * 12-hex id) — none can contain `]` or `|`, so the first `]` unambiguously closes + * the bracket and `` (free single-line text, may contain `]`/`|`/quotes) is + * everything after `] `. `parseProfile(serializeProfile(doc)) === doc` (SC2). + */ + +import { mintEntityId } from "./id.js"; +import { SCHEMA_VERSION } from "./types.js"; +import type { FactStatus, ProfileDoc, ProfileFact, Provenance } from "./types.js"; + +const STATIC_HEADER = "## Static"; +const DYNAMIC_HEADER = "## Dynamic"; + +/** + * One fact line. Tokens are pipe-joined in a fixed order; an empty value emits no + * trailing space (so the common source-absent fact is a clean `- [tokens]`). + */ +function serializeFact(f: ProfileFact): string { + const bracket = [ + f.provenance, + f.status, + f.first_seen, + f.last_seen, + String(f.evidence_count), + f.id, + ].join("|"); + return f.value === "" ? `- [${bracket}]` : `- [${bracket}] ${f.value}`; +} + +/** Deterministic full-document serialization; both sections always present. */ +export function serializeProfile(doc: ProfileDoc): string { + const lines: string[] = [ + "# Profile", + "", + `schemaVersion: ${doc.schemaVersion}`, + "", + STATIC_HEADER, + "", + ...doc.static.map(serializeFact), + "", + DYNAMIC_HEADER, + "", + ...doc.dynamic.map(serializeFact), + ]; + return lines.join("\n") + "\n"; +} + +const FACT_RE = + /^- \[(human|published|ai-draft)\|(active|superseded)\|(\d{4}-\d{2}-\d{2})\|(\d{4}-\d{2}-\d{2})\|(\d+)\|([0-9a-f]{12})\](?: (.*))?$/; + +/** + * Parse a profile document back into the typed two-layer doc. Lines that are not + * fact lines or section headers (blank lines, the title) carry no data and are + * skipped — the fact lines ARE the authoritative representation (C1), so nothing + * data-bearing is lost, and `parse ∘ serialize` is a true identity. + */ +export function parseProfile(text: string): ProfileDoc { + const schemaMatch = text.match(/^schemaVersion:\s*(\d+)\s*$/m); + const version = schemaMatch ? Number(schemaMatch[1]) : SCHEMA_VERSION; + if (version !== SCHEMA_VERSION) { + throw new Error(`unsupported profile schemaVersion: ${version} (expected ${SCHEMA_VERSION})`); + } + + const staticFacts: ProfileFact[] = []; + const dynamicFacts: ProfileFact[] = []; + let section: "static" | "dynamic" | null = null; + + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed === STATIC_HEADER) { + section = "static"; + continue; + } + if (trimmed === DYNAMIC_HEADER) { + section = "dynamic"; + continue; + } + const m = line.match(FACT_RE); + if (!m) continue; + const fact: ProfileFact = { + provenance: m[1] as Provenance, + status: m[2] as FactStatus, + first_seen: m[3], + last_seen: m[4], + evidence_count: Number(m[5]), + id: m[6], + value: m[7] ?? "", + }; + if (section === "static") staticFacts.push(fact); + else if (section === "dynamic") dynamicFacts.push(fact); + } + + return { schemaVersion: SCHEMA_VERSION, static: staticFacts, dynamic: dynamicFacts }; +} + +// ── user-profile fold ────────────────────────────────────────────────────────── +// +// Folds the field-set of `config/user-profile.template.md` into the static layer +// (M4: extraction pinned against the real template). Exactly two productions, and +// a hard section boundary: +// +// P1 — labeled scalar: `**Label:** value` (optionally `- ` prefixed). A line +// whose value is EMPTY is a GROUP HEADER (`**Signature Elements:**`) and is +// skipped — only the scalar fields under it fold. A `[placeholder]` value +// is stored EMPTY (unfilled); a literal value is kept. +// P2 — expertise group: numbered lines under `**Core Expertise Areas …:**` +// become `expertise-area-N` fields. +// Boundary — extraction STOPS at the `### Research Tooling` section, so the +// deferred Research-MCP / Asset-Utilization prose (which uses the same +// `**Label:** value` syntax for explainer text) cannot leak in as fields. +// +// Deferred to a later slice when the profile schema grows (§8): the checkbox +// preference groups (Goals, Tone, MCPs, Assets), Content Style Mix, and the +// Voice-Profile-Summary qualities — none of which match P1/P2. + +const PROFILE_FIELD_KIND = "profile-field"; + +interface ExtractedField { + label: string; + value: string; +} + +/** A leading `[...]` bracketed placeholder means the field is unfilled → empty. */ +function placeholderToEmpty(raw: string): string { + return /^\[[^\]]*\]/.test(raw) ? "" : raw; +} + +/** Apply P1 + P2 over the template/instance text up to the Research-Tooling boundary. */ +function extractFields(rawText: string): ExtractedField[] { + const cutoff = rawText.search(/^###\s+Research Tooling\b/m); + const text = cutoff === -1 ? rawText : rawText.slice(0, cutoff); + + const fields: ExtractedField[] = []; + let inExpertise = false; + let expertiseIndex = 0; + + for (const line of text.split("\n")) { + // P2 anchor — the expertise group header (value-less by construction). + if (/^\s*\*\*Core Expertise Areas\b.*:\*\*\s*$/.test(line)) { + inExpertise = true; + expertiseIndex = 0; + continue; + } + if (inExpertise) { + const item = line.match(/^\s*(\d+)\.\s+(.*)$/); + if (item) { + expertiseIndex += 1; + fields.push({ label: `expertise-area-${expertiseIndex}`, value: placeholderToEmpty(item[2].trim()) }); + continue; + } + // A non-blank, non-numbered line closes the group; blanks keep it open. + if (line.trim() !== "") inExpertise = false; + } + // P1 — labeled scalar. + const m = line.match(/^\s*(?:- )?\*\*(.+?):\*\*\s*(.*)$/); + if (m) { + const value = m[2].trim(); + if (value === "") continue; // group header — owned by its sub-fields, not a fact + fields.push({ label: m[1].trim(), value: placeholderToEmpty(value) }); + } + } + return fields; +} + +/** + * Fold the user-profile field-set into a two-layer `ProfileDoc.static`. The + * TEMPLATE defines the canonical field-set; an optional runtime INSTANCE supplies + * filled values; an optional EXISTING doc makes the fold idempotent. + * + * Lossless + idempotent (SC3): + * - source-absent → every template field present, placeholder values empty; + * - populated → each filled instance value carried onto the matching field; + * - re-run → no duplication, a non-empty value is never overwritten, only + * `last_seen` is bumped to `today`. + * + * Pure — the caller supplies the file texts and the run date (`today`). + */ +export function foldUserProfile(opts: { + templateText: string; + instanceText?: string; + existing?: ProfileDoc; + today: string; +}): ProfileDoc { + const { templateText, instanceText, existing, today } = opts; + + const templateFields = extractFields(templateText); + const instanceByLabel = new Map( + (instanceText ? extractFields(instanceText) : []).map((f) => [f.label, f.value]), + ); + const existingById = new Map((existing?.static ?? []).map((f) => [f.id, f])); + + const staticFacts: ProfileFact[] = templateFields.map((tf) => { + const id = mintEntityId({ kind: PROFILE_FIELD_KIND, key: tf.label }); + const instanceValue = instanceByLabel.get(tf.label); + // Prefer a filled instance value over the template's (possibly empty) value. + const incomingValue = instanceValue !== undefined && instanceValue !== "" ? instanceValue : tf.value; + + const prev = existingById.get(id); + if (prev) { + // Idempotent merge: never overwrite a non-empty value; fill empties; bump last_seen. + return { ...prev, value: prev.value !== "" ? prev.value : incomingValue, last_seen: today }; + } + return { + id, + value: incomingValue, + first_seen: today, + last_seen: today, + evidence_count: 0, + provenance: "human", + status: "active", + }; + }); + + return { schemaVersion: SCHEMA_VERSION, static: staticFacts, dynamic: existing?.dynamic ?? [] }; +} diff --git a/scripts/brain/src/scaffold.ts b/scripts/brain/src/scaffold.ts new file mode 100644 index 0000000..63903fb --- /dev/null +++ b/scripts/brain/src/scaffold.ts @@ -0,0 +1,117 @@ +/** + * `initBrain` — the idempotent runtime scaffold for the second brain (SB-S0 D1/D5). + * + * Creates the `brain/` + `ingest/` tree under the per-user data dir (via the + * `dataRoot` resolver, so `LINKEDIN_STUDIO_DATA` relocates it for tests + real + * installs). Idempotent + migration-safe: a directory that exists is left alone, + * a file that exists is NEVER rewritten (compare-then-skip, never blind-write) — + * so a second run is a no-op and a user-edited file is preserved. + * + * NO session-start wiring (SB-S2 owns that) — this only ships the invokable init. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { dataRoot } from "./dataRoot.js"; +import { foldUserProfile, serializeProfile } from "./profile.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +/** The shipped template that defines the canonical profile field-set. */ +const TEMPLATE_PATH = join(HERE, "..", "..", "..", "config", "user-profile.template.md"); +/** The runtime instance (if the user copied + filled the template). */ +const INSTANCE_SUB = join("profile", "user-profile.md"); + +const SCAFFOLD_DIRS = ["brain/journal", "ingest/inbox", "ingest/published"]; + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function indexSeed(): string { + return `# Brain — Index (MOC) + +> Map of Content — one screen pointing at every tributary, with a freshness flag. +> Generated by \`brain init\`; safe to hand-edit (a re-run never clobbers it). + +| Tributary | What it holds | Freshness | +|-----------|---------------|-----------| +| voice-samples | writing style | — | +| specifics-bank | lived raw material | — | +| trends | external signal | — | +| analytics | performance | — | +| ingest | inbox + published (the gold signal) | — | + +- [profile.md](profile.md) — semantic, two-layer (static + dynamic) profile +- [operations.md](operations.md) — plans · ideas · the "who I am now" anchor +- journal/ — episodic, append-only session log (the consolidation loop's source) +`; +} + +function operationsSeed(): string { + return `# Operations + +## Who I am now (anchor) + + + +## Plans + +## Ideas +`; +} + +function profileSeed(): string { + const templateText = readFileSync(TEMPLATE_PATH, "utf8"); + const instancePath = dataRoot(INSTANCE_SUB); + const instanceText = existsSync(instancePath) ? readFileSync(instancePath, "utf8") : undefined; + return serializeProfile(foldUserProfile({ templateText, instanceText, today: today() })); +} + +export interface InitResult { + /** Data-relative paths created this run. */ + created: string[]; + /** Data-relative paths that already existed and were left untouched. */ + skipped: string[]; +} + +/** + * Create the brain scaffold idempotently. Returns the created/skipped split. + * File content seeds are computed lazily, so an already-present file costs no + * template read or fold. + */ +export function initBrain(): InitResult { + const created: string[] = []; + const skipped: string[] = []; + + for (const sub of SCAFFOLD_DIRS) { + const abs = dataRoot(sub); + if (existsSync(abs)) { + skipped.push(sub); + } else { + mkdirSync(abs, { recursive: true }); + created.push(sub); + } + } + + const files: Array<{ sub: string; seed: () => string }> = [ + { sub: "brain/profile.md", seed: profileSeed }, + { sub: "brain/index.md", seed: indexSeed }, + { sub: "brain/operations.md", seed: operationsSeed }, + ]; + + for (const { sub, seed } of files) { + const abs = dataRoot(sub); + if (existsSync(abs)) { + skipped.push(sub); + continue; + } + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, seed(), "utf8"); + created.push(sub); + } + + return { created, skipped }; +} diff --git a/scripts/brain/src/types.ts b/scripts/brain/src/types.ts new file mode 100644 index 0000000..341be1d --- /dev/null +++ b/scripts/brain/src/types.ts @@ -0,0 +1,55 @@ +/** + * Shared types + constants for the second-brain foundation (SB-S0). + * + * The two invariants this module pins (architecture.md §"Invariants"): + * - ONE provenance shape across the whole brain: `human | published | ai-draft`. + * The model-collapse guard (profile/voice learn from `published` only) hangs on + * this being the single source of truth, not reinvented per silo. + * - ONE canonical entity-id + one fact record shape (the six fields below), so a + * later slice can thread the id through tributaries (SB-S3) without re-deciding + * the record schema. + */ + +/** The single provenance vocabulary for the whole brain. */ +export type Provenance = "human" | "published" | "ai-draft"; + +/** A fact is `active` until a later, superseding fact demotes it (SB-S2). */ +export type FactStatus = "active" | "superseded"; + +/** The accepted provenance values, as a runtime list (used by normalizeProvenance). */ +export const PROVENANCE_VALUES: readonly Provenance[] = ["human", "published", "ai-draft"] as const; + +/** The accepted fact-status values, as a runtime list. */ +export const FACT_STATUS_VALUES: readonly FactStatus[] = ["active", "superseded"] as const; + +/** The profile schema version — bumped only on a breaking grammar change. */ +export const SCHEMA_VERSION = 1 as const; + +/** + * One distilled fact in `brain/profile.md`. Every fact carries all six fields so + * the consolidation loop (SB-S2) has evidence/provenance/temporal context to + * promote, decay, and reconcile against. + */ +export interface ProfileFact { + /** Canonical entity id (id.ts), keyed on a stable slug — stable across value edits. */ + id: string; + /** Single-line value (no embedded newline — enforced by the line-grammar). */ + value: string; + /** YYYY-MM-DD — first observation. */ + first_seen: string; + /** YYYY-MM-DD — most recent observation. */ + last_seen: string; + /** How many independent observations back this fact (threshold-promotion, SB-S2). */ + evidence_count: number; + /** Where the fact came from — drives provenance-weighted learning. */ + provenance: Provenance; + /** `active` or `superseded`. */ + status: FactStatus; +} + +/** The two-layer profile document: stable `static` facts + evolving `dynamic` facts. */ +export interface ProfileDoc { + schemaVersion: typeof SCHEMA_VERSION; + static: ProfileFact[]; + dynamic: ProfileFact[]; +} diff --git a/scripts/brain/tests/fold.test.ts b/scripts/brain/tests/fold.test.ts new file mode 100644 index 0000000..c29432b --- /dev/null +++ b/scripts/brain/tests/fold.test.ts @@ -0,0 +1,113 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { foldUserProfile } from "../src/profile.js"; +import { mintEntityId } from "../src/id.js"; +import type { ProfileDoc } from "../src/types.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const TEMPLATE = readFileSync(join(here, "../../../config/user-profile.template.md"), "utf8"); +const POPULATED = readFileSync(join(here, "../fixtures/user-profile.populated.md"), "utf8"); + +const D1 = "2026-06-23"; +const D2 = "2026-07-01"; + +/** Look a fact up by its source label via the canonical id. */ +const byLabel = (doc: ProfileDoc, label: string) => + doc.static.find((f) => f.id === mintEntityId({ kind: "profile-field", key: label })); + +const FIELD_COUNT = 27; // 22 P1 labeled scalars + 5 P2 expertise areas + +describe("foldUserProfile (SC3)", () => { + describe("SC3a — source-absent", () => { + const doc = foldUserProfile({ templateText: TEMPLATE, today: D1 }); + + test("lists every template field (22 P1 + 5 P2), no dynamic facts", () => { + assert.equal(doc.static.length, FIELD_COUNT); + assert.equal(doc.dynamic.length, 0); + }); + + test("placeholder fields fold to an empty value", () => { + assert.equal(byLabel(doc, "Name")?.value, ""); + assert.equal(byLabel(doc, "Current Role")?.value, ""); + assert.equal(byLabel(doc, "expertise-area-1")?.value, ""); + assert.equal(byLabel(doc, "Follower count")?.value, ""); + assert.equal(byLabel(doc, "Topics to AVOID")?.value, ""); + }); + + test("the lone literal template field (Disclaimer) keeps its boilerplate", () => { + assert.equal(byLabel(doc, "Important Disclaimer")?.value.startsWith("All articles"), true); + }); + + test("deferred Research-Tooling prose does not leak in as fields", () => { + assert.equal(byLabel(doc, "Always-available floor (no MCP needed)"), undefined); + assert.equal(byLabel(doc, "Preferred order (optional)"), undefined); + }); + + test("every folded fact has the canonical shape", () => { + for (const f of doc.static) { + assert.match(f.id, /^[0-9a-f]{12}$/); + assert.equal(f.provenance, "human"); + assert.equal(f.status, "active"); + assert.equal(f.evidence_count, 0); + assert.equal(f.first_seen, D1); + assert.equal(f.last_seen, D1); + } + }); + }); + + describe("SC3b — populated (lossless)", () => { + const doc = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, today: D1 }); + + test("same field-set size — fold neither drops nor invents fields", () => { + assert.equal(doc.static.length, FIELD_COUNT); + }); + + test("every filled value is carried (none dropped)", () => { + assert.equal(doc.static.every((f) => f.value !== ""), true); + }); + + test("specific filled values land on the right fields", () => { + assert.equal(byLabel(doc, "Name")?.value, "Jordan Avery"); + assert.equal(byLabel(doc, "Current Role")?.value, "Senior Data Engineer"); + assert.equal(byLabel(doc, "expertise-area-1")?.value, "Streaming data pipelines"); + assert.equal(byLabel(doc, "expertise-area-5")?.value, "Team data literacy"); + assert.equal(byLabel(doc, "Topics to AVOID")?.value, "Crypto"); + assert.equal(byLabel(doc, "Follower count")?.value, "2,400"); + assert.equal(byLabel(doc, "Phrases you commonly use")?.value, '"ship the boring thing"'); + }); + }); + + describe("SC3c — idempotent re-run", () => { + test("re-running source-absent over its own output changes nothing", () => { + const a = foldUserProfile({ templateText: TEMPLATE, today: D1 }); + const a2 = foldUserProfile({ templateText: TEMPLATE, existing: a, today: D1 }); + assert.deepEqual(a2, a); + }); + + test("re-running populated over its own output changes nothing (no dup)", () => { + const b = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, today: D1 }); + const b2 = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, existing: b, today: D1 }); + assert.deepEqual(b2, b); + assert.equal(b2.static.length, FIELD_COUNT); + }); + + test("a later run bumps last_seen but preserves first_seen and value", () => { + const b = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, today: D1 }); + const later = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, existing: b, today: D2 }); + const name = byLabel(later, "Name"); + assert.equal(name?.first_seen, D1); + assert.equal(name?.last_seen, D2); + assert.equal(name?.value, "Jordan Avery"); + }); + + test("re-folding source-absent over a populated existing never wipes a filled value", () => { + const b = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, today: D1 }); + const over = foldUserProfile({ templateText: TEMPLATE, existing: b, today: D2 }); + assert.equal(byLabel(over, "Name")?.value, "Jordan Avery"); + }); + }); +}); diff --git a/scripts/brain/tests/id.test.ts b/scripts/brain/tests/id.test.ts new file mode 100644 index 0000000..53b69dd --- /dev/null +++ b/scripts/brain/tests/id.test.ts @@ -0,0 +1,73 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { slugify, mintEntityId, normalizeProvenance } from "../src/id.js"; + +describe("id module (SC4)", () => { + describe("slugify", () => { + test("lowercases, trims, and dashes non-alphanumeric runs", () => { + assert.equal(slugify("Current Role"), "current-role"); + }); + + test("collapses repeated separators and strips edge dashes", () => { + assert.equal(slugify(" Core Expertise Areas (5 topics) "), "core-expertise-areas-5-topics"); + }); + + test("is stable across case + whitespace variation of the same label", () => { + assert.equal(slugify("Current Role"), slugify(" current ROLE ")); + }); + }); + + describe("mintEntityId", () => { + test("is deterministic — same seed yields the same id", () => { + assert.equal( + mintEntityId({ kind: "profile-field", key: "Current Role" }), + mintEntityId({ kind: "profile-field", key: "Current Role" }), + ); + }); + + test("is stable across label case/whitespace (id keyed on the slug)", () => { + assert.equal( + mintEntityId({ kind: "profile-field", key: "Current Role" }), + mintEntityId({ kind: "profile-field", key: " current ROLE " }), + ); + }); + + test("different key yields a different id", () => { + assert.notEqual( + mintEntityId({ kind: "profile-field", key: "Current Role" }), + mintEntityId({ kind: "profile-field", key: "Organization" }), + ); + }); + + test("different kind yields a different id (kind is part of the seed)", () => { + assert.notEqual( + mintEntityId({ kind: "profile-field", key: "role" }), + mintEntityId({ kind: "expertise-area", key: "role" }), + ); + }); + + test("id is 12 lowercase hex characters", () => { + assert.match(mintEntityId({ kind: "profile-field", key: "Name" }), /^[0-9a-f]{12}$/); + }); + }); + + describe("normalizeProvenance", () => { + test("returns each accepted value unchanged", () => { + assert.equal(normalizeProvenance("human"), "human"); + assert.equal(normalizeProvenance("published"), "published"); + assert.equal(normalizeProvenance("ai-draft"), "ai-draft"); + }); + + test("trims and lowercases before matching", () => { + assert.equal(normalizeProvenance(" Human "), "human"); + assert.equal(normalizeProvenance("AI-DRAFT"), "ai-draft"); + }); + + test("throws on anything outside the provenance vocabulary", () => { + assert.throws(() => normalizeProvenance("robot")); + assert.throws(() => normalizeProvenance("")); + assert.throws(() => normalizeProvenance("humanoid")); + }); + }); +}); diff --git a/scripts/brain/tests/profile.test.ts b/scripts/brain/tests/profile.test.ts new file mode 100644 index 0000000..43b9eeb --- /dev/null +++ b/scripts/brain/tests/profile.test.ts @@ -0,0 +1,80 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { parseProfile, serializeProfile } from "../src/profile.js"; +import { SCHEMA_VERSION } from "../src/types.js"; +import type { ProfileDoc, ProfileFact } from "../src/types.js"; + +const fact = (over: Partial = {}): ProfileFact => ({ + id: "abc123def456", + value: "a value", + first_seen: "2026-06-23", + last_seen: "2026-06-23", + evidence_count: 0, + provenance: "human", + status: "active", + ...over, +}); + +describe("profile line-grammar (SC2)", () => { + test("serialized text exposes both section headers", () => { + const text = serializeProfile({ schemaVersion: SCHEMA_VERSION, static: [], dynamic: [] }); + assert.match(text, /^## Static$/m); + assert.match(text, /^## Dynamic$/m); + assert.match(text, /^schemaVersion: 1$/m); + }); + + test("parse ∘ serialize is identity over the whole doc", () => { + const doc: ProfileDoc = { + schemaVersion: SCHEMA_VERSION, + static: [ + fact({ id: "0123456789ab", value: "Kjell Tore", provenance: "human" }), + fact({ id: "fedcba987654", value: "", evidence_count: 3, provenance: "published" }), + ], + dynamic: [ + fact({ id: "aaaabbbbcccc", value: "leans contrarian", status: "superseded", last_seen: "2026-06-20" }), + ], + }; + assert.deepEqual(parseProfile(serializeProfile(doc)), doc); + }); + + test("a value containing ], | and quotes round-trips intact", () => { + const tricky = 'uses "scare quotes" | pipes | and a ] bracket'; + const doc: ProfileDoc = { + schemaVersion: SCHEMA_VERSION, + static: [fact({ value: tricky })], + dynamic: [], + }; + const round = parseProfile(serializeProfile(doc)); + assert.equal(round.static[0].value, tricky); + assert.deepEqual(round, doc); + }); + + test("an empty-value fact round-trips to an empty string (the source-absent case)", () => { + const doc: ProfileDoc = { + schemaVersion: SCHEMA_VERSION, + static: [fact({ value: "" })], + dynamic: [], + }; + const round = parseProfile(serializeProfile(doc)); + assert.equal(round.static[0].value, ""); + assert.deepEqual(round, doc); + }); + + test("section attribution is preserved (static vs dynamic do not bleed)", () => { + const doc: ProfileDoc = { + schemaVersion: SCHEMA_VERSION, + static: [fact({ id: "111111111111", value: "static one" })], + dynamic: [fact({ id: "222222222222", value: "dynamic one" })], + }; + const round = parseProfile(serializeProfile(doc)); + assert.equal(round.static.length, 1); + assert.equal(round.dynamic.length, 1); + assert.equal(round.static[0].value, "static one"); + assert.equal(round.dynamic[0].value, "dynamic one"); + }); + + test("an unsupported schemaVersion throws", () => { + assert.throws(() => parseProfile("# Profile\n\nschemaVersion: 99\n\n## Static\n\n## Dynamic\n")); + }); +}); diff --git a/scripts/brain/tests/scaffold.test.ts b/scripts/brain/tests/scaffold.test.ts new file mode 100644 index 0000000..f768ae5 --- /dev/null +++ b/scripts/brain/tests/scaffold.test.ts @@ -0,0 +1,78 @@ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { initBrain } from "../src/scaffold.js"; +import { parseProfile } from "../src/profile.js"; + +const EXPECTED_DIRS = ["brain", "brain/journal", "ingest", "ingest/inbox", "ingest/published"]; +const EXPECTED_FILES = ["brain/profile.md", "brain/index.md", "brain/operations.md"]; + +describe("initBrain scaffold (SC1)", () => { + let root: string; + const prevEnv = process.env.LINKEDIN_STUDIO_DATA; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "brain-scaffold-")); + process.env.LINKEDIN_STUDIO_DATA = root; + }); + + afterEach(() => { + if (prevEnv === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = prevEnv; + rmSync(root, { recursive: true, force: true }); + }); + + test("creates the full brain/ + ingest/ tree at the runtime data-path", () => { + const res = initBrain(); + for (const d of EXPECTED_DIRS) { + assert.ok(existsSync(join(root, d)) && statSync(join(root, d)).isDirectory(), `dir ${d}`); + } + for (const f of EXPECTED_FILES) { + assert.ok(existsSync(join(root, f)), `file ${f}`); + } + // first run reports everything as created, nothing skipped + assert.equal(res.skipped.length, 0); + assert.ok(res.created.length >= EXPECTED_FILES.length); + }); + + test("profile.md is a parseable two-layer doc seeded from the template", () => { + initBrain(); + const doc = parseProfile(readFileSync(join(root, "brain/profile.md"), "utf8")); + assert.equal(doc.schemaVersion, 1); + assert.ok(doc.static.length > 0, "static layer seeded from the template field-set"); + }); + + test("index.md and operations.md carry their real seed anchors", () => { + initBrain(); + const index = readFileSync(join(root, "brain/index.md"), "utf8"); + const ops = readFileSync(join(root, "brain/operations.md"), "utf8"); + for (const trib of ["voice-samples", "specifics-bank", "trends", "analytics", "ingest"]) { + assert.match(index, new RegExp(trib), `index mentions ${trib}`); + } + assert.match(ops, /Who I am now/); + assert.match(ops, /## Plans/); + assert.match(ops, /## Ideas/); + }); + + test("a second invocation is a no-op — every target skipped, content unchanged", () => { + initBrain(); + const before = EXPECTED_FILES.map((f) => readFileSync(join(root, f), "utf8")); + const res2 = initBrain(); + assert.equal(res2.created.length, 0, "nothing re-created"); + assert.ok(res2.skipped.length >= EXPECTED_FILES.length, "everything skipped"); + const after = EXPECTED_FILES.map((f) => readFileSync(join(root, f), "utf8")); + assert.deepEqual(after, before, "no clobber of existing content"); + }); + + test("a second invocation never clobbers a user-edited file", () => { + initBrain(); + const opsPath = join(root, "brain/operations.md"); + const edited = readFileSync(opsPath, "utf8") + "\n## Plans\n- ship SB-S1\n"; + writeFileSync(opsPath, edited, "utf8"); + initBrain(); + assert.equal(readFileSync(opsPath, "utf8"), edited, "user edit preserved"); + }); +}); diff --git a/scripts/brain/tsconfig.json b/scripts/brain/tsconfig.json new file mode 100644 index 0000000..eaa1e8e --- /dev/null +++ b/scripts/brain/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"] +} diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index 8ada9a6..5e815c4 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -695,6 +695,34 @@ fi echo "" +# --- Section 16b: Brain Foundation (SB-S0) --- +echo "--- Brain Foundation ---" + +# The second-brain foundation (scripts/brain, SB-S0) ships the brain/ scaffold +# initialiser, the two-layer profile.md line-grammar, the lossless+idempotent +# user-profile fold, and the canonical entity-id + provenance module. This is the +# spine later slices hang on (the id threads through tributaries in SB-S3, the +# evolution loop consumes the profile in SB-S2), so the suite stays green and its +# case count never erodes. KTG-internal: skipped (warn, never fail) for an adopter +# that ships no brain deps. Same set +e / subshell discipline as Sections 14-16 +# (bash 3.2-safe; keeps a red npm test from aborting the runner under set -e). +BR_DIR="scripts/brain" +if [ -x "$BR_DIR/node_modules/.bin/tsx" ]; then + BR_OUT=$( set +e; (cd "$BR_DIR" && npm test) 2>&1; echo "BR_EXIT:$?" ) + BR_EXIT=$(echo "$BR_OUT" | grep -oE 'BR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) + BR_TESTS=$(echo "$BR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) + BRAIN_TESTS_FLOOR=34 # SB-S0: id(11) + profile(6) + fold(12) + scaffold(5) + if [ "$BR_EXIT" = "0" ] && [ -n "$BR_TESTS" ] && [ "$BR_TESTS" -ge "$BRAIN_TESTS_FLOOR" ]; then + pass "brain suite green: $BR_TESTS tests pass (floor $BRAIN_TESTS_FLOOR)" + else + fail "brain suite NOT green (exit=${BR_EXIT:-?}, tests=${BR_TESTS:-?}, floor $BRAIN_TESTS_FLOOR) — run: (cd $BR_DIR && npm test)" + fi +else + warn "brain skipped — deps absent ($BR_DIR/node_modules); run: (cd $BR_DIR && npm install)" +fi + +echo "" + # --- Section 17: De-Niche Guard (B-S1 + B-S2) --- echo "--- De-Niche Guard ---" @@ -764,14 +792,15 @@ echo "" # --- Section 18: Assertion-Count Anti-Erosion (SC6) --- # The lint self-modifies its own checks, so a green run could mask a silently dropped -# assertion. Pin the pre-M0 total (74 pass()+fail() invocations) as a floor; the count -# may only grow (brief-reviewer assumption 3). Runs last so TOTAL_CHECKS sees every prior check. -ASSERT_BASELINE_PRE_M0=74 +# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count +# may only grow (brief-reviewer assumption 3). History: 74 pre-M0; +1 for the SB-S0 +# brain-suite floor (Section 16b) = 75. Runs last so TOTAL_CHECKS sees every prior check. +ASSERT_BASELINE_FLOOR=75 TOTAL_CHECKS=$((PASS + FAIL)) -if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_PRE_M0" ]; then - pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= pre-M0 baseline $ASSERT_BASELINE_PRE_M0" +if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then + pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" else - fail "assertion count $TOTAL_CHECKS < pre-M0 baseline $ASSERT_BASELINE_PRE_M0 — a check was silently removed" + fail "assertion count $TOTAL_CHECKS < baseline floor $ASSERT_BASELINE_FLOOR — a check was silently removed" fi echo ""