/** * Deterministic store + query for the lived-specifics bank (Fix #2). * * Pure where it matters: id derivation, dedupe, and topic query are * side-effect-free and fully testable. Only loadBank/saveBank touch the * filesystem. No AI, no network — the bank is reliable inventory, not a * creatively-interpreted blob. The elicitation that POPULATES the bank lives in * the command layer (a guided interview that refuses vague answers); this module * only stores, dedupes, and serves what the operator actually supplied. */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { createHash } from "node:crypto"; import { SCHEMA_VERSION } from "./types.js"; import type { Bank, Provenance, QueryHit, Specific, SpecificStatus, SpecificType, Verification, } from "./types.js"; export { SCHEMA_VERSION } from "./types.js"; /** What a caller supplies to addSpecific — the id is derived, never passed in. */ export interface SpecificInput { type: SpecificType; /** The operator's raw material, verbatim. */ content: string; topicTags: string[]; provenance: Provenance; /** Defaults to "unverified" (numbers stay guilty-until-checked — regel 6/7). */ verification?: Verification; /** Defaults to "active". */ status?: SpecificStatus; } export interface AddResult { bank: Bank; /** true iff a new specific was appended (false = duplicate content). */ added: boolean; /** true iff an existing duplicate gained new topic tags via union. */ merged: boolean; } /** Lowercase + trim + collapse all whitespace runs to a single space. */ export function normalizeContent(content: string): string { return content.trim().toLowerCase().replace(/\s+/g, " "); } /** Stable id = first 12 hex of sha256(normalizeContent) — also the dedupe key. */ export function specificId(content: string): string { return createHash("sha256").update(normalizeContent(content)).digest("hex").slice(0, 12); } export function emptyBank(): Bank { return { schemaVersion: SCHEMA_VERSION, specifics: [] }; } /** Read the bank; a missing file is an empty bank (never throws on absence). */ export function loadBank(path: string): Bank { if (!existsSync(path)) return emptyBank(); const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; return { schemaVersion: parsed.schemaVersion ?? SCHEMA_VERSION, specifics: Array.isArray(parsed.specifics) ? parsed.specifics : [], }; } /** Write the bank as pretty JSON, creating the parent dir if needed. */ export function saveBank(path: string, bank: Bank): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(bank, null, 2) + "\n", "utf8"); } /** Union of two tag lists, order-stable on the first list, case-insensitive dedupe. */ function unionTags(existing: string[], incoming: string[]): { tags: string[]; changed: boolean } { const seen = new Set(existing.map((t) => t.toLowerCase())); const tags = [...existing]; let changed = false; for (const t of incoming) { if (!seen.has(t.toLowerCase())) { seen.add(t.toLowerCase()); tags.push(t); changed = true; } } return { tags, changed }; } /** * Add a specific, deduping on normalized content. A duplicate does not append a * second entry — instead its topic tags are unioned in, so the same real * material re-surfaced under a new edition's topic enriches the existing record. * The operator's `content` is stored VERBATIM; only the id is normalized. */ export function addSpecific(bank: Bank, input: SpecificInput): AddResult { const id = specificId(input.content); const existing = bank.specifics.find((s) => s.id === id); if (existing) { const { tags, changed } = unionTags(existing.topicTags, input.topicTags); existing.topicTags = tags; return { bank, added: false, merged: changed }; } const specific: Specific = { id, type: input.type, content: input.content, topicTags: [...input.topicTags], provenance: input.provenance, verification: input.verification ?? "unverified", status: input.status ?? "active", }; bank.specifics.push(specific); return { bank, added: true, merged: false }; } /** * Active specifics whose tags overlap the query, ranked by overlap (desc) then * recency (capturedAt desc). Tag matching is case-insensitive. Non-matches and * archived material are excluded. */ export function queryByTopic(bank: Bank, tags: string[]): QueryHit[] { const wanted = tags.map((t) => t.toLowerCase()); const hits: QueryHit[] = []; for (const specific of bank.specifics) { if (specific.status !== "active") continue; const have = new Set(specific.topicTags.map((t) => t.toLowerCase())); const tagOverlap = wanted.reduce((n, t) => (have.has(t) ? n + 1 : n), 0); if (tagOverlap > 0) hits.push({ specific, tagOverlap }); } hits.sort( (a, b) => b.tagOverlap - a.tagOverlap || b.specific.provenance.capturedAt.localeCompare(a.specific.provenance.capturedAt), ); return hits; } /** * Default bank path under the per-user data dir (M0 data-path convention), so the * bank survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` overrides the * root; otherwise `~/.claude/linkedin-studio`. */ export function defaultBankPath(): string { const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); return join(root, "specifics-bank", "specifics-bank.json"); }