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>
153 lines
5.4 KiB
TypeScript
153 lines
5.4 KiB
TypeScript
/**
|
|
* 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");
|
|
}
|