/** * SB-S3e — triple-post reconciliation (the LAST S3 slice, read-side). * * Silo 1 (`## Recent Posts` in the runtime state file) is the AUTO-tracked stream * of posts created via the plugin (written by `updatePostTracking`, * state-updater.mjs:116). Silo 2 (`ingest/published/.md`) + silo 3 (analytics) * are joined by SB-S3c's `assemblePostGraph`, but ONLY for posts the user MANUALLY * ran `brain ingest` on. So the graph is blind to everything created-but-never- * ingested. This module reconciles silo 1 against the graph and surfaces that * COVERAGE GAP — read-only, never writing the state silo. * * Honest limit: silo 1 carries only a ≤60-char (possibly `…`-truncated) hook * preview, a weaker signal than silo 2's full body; below a floor it cannot * discriminate → `orphaned-in-state`. Read-side cannot reconstruct the * specifics/trends a post was built from — that needs auto-capture (a follow-up). * * PURE core: `parseRecentPosts` / `reconcileRecentPosts` / `summarizeReconcile` * take strings/objects and return data — no FS/clock/network. The only IO is * `loadRecentPosts`, which reads the state file via the canonical `getStateFile()` * precedence (`STATE_FILE` first) — a DIFFERENT root than the brain dataRoot. */ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { normalize, type PostGraphNode } from "./assemble.js"; import type { PublishedRecord } from "./ingest.js"; export interface RecentPost { date: string; hook: string; charCount: number; topic: string; } export type ReconcileTier = "in-graph" | "in-brain-only" | "orphaned-in-state"; export interface ReconcileNode { recentPost: RecentPost; tier: ReconcileTier; /** The matched published record's id, when a record matched. */ contentId?: string; } export interface ReconcileSummary { created: number; inGraph: number; inBrainOnly: number; orphaned: number; } /** * Minimum normalized-hook length to attempt a prefix match. Mirrors * `assemble.ts`'s `PREFIX_FLOOR` (24) — the brain-local-copy idiom (dataRoot.ts * header): the hook preview floor below which a prefix match is coincidental. */ const HOOK_PREFIX_FLOOR = 24; /** Strip a trailing LinkedIn truncation marker so a `…`-suffixed hook still prefix-matches (assemble.ts idiom). */ function stripTrailingEllipsis(s: string): string { return s.replace(/(?:…|\.{3})\s*$/, "").trimEnd(); } /** * Parse the `## Recent Posts` section into structured entries. The format source * of truth is the WRITER (`updatePostTracking`, state-updater.mjs:116): * `- [YYYY-MM-DD] "hook" (chars) - topic`. (NOT `pruneContentHistory`'s :145 regex, * which is date-only.) Capture/`match` only — no `String.replace` — so a `$`-bearing * hook/topic round-trips verbatim with no injection. Pure. */ export function parseRecentPosts(stateText: string): RecentPost[] { // Line-walk from the `## Recent Posts` heading to the next `## ` heading (or EOF). // Line-based (not one lookahead regex) so a blank line right after the heading // can't collapse the capture. const lines = stateText.split("\n"); const start = lines.findIndex((l) => /^## Recent Posts[ \t]*$/.test(l)); if (start === -1) return []; const entry = /^- \[(\d{4}-\d{2}-\d{2})\] "(.*)" \((\d+)\) - (.+)$/; const out: RecentPost[] = []; for (let i = start + 1; i < lines.length; i++) { if (/^## /.test(lines[i])) break; // next section ends the block const m = lines[i].match(entry); if (m) out.push({ date: m[1], hook: m[2], charCount: Number(m[3]), topic: m[4] }); } return out; } /** * Find the published record a silo-1 hook corresponds to: the hook (ellipsis- * stripped, normalized) must be a PREFIX of the record body, and long enough to * discriminate. Among matches, prefer one whose `published_date` equals the * entry's date (deterministic), else the first in input order. */ function matchRecord(post: RecentPost, records: PublishedRecord[]): PublishedRecord | undefined { const nh = stripTrailingEllipsis(normalize(post.hook)); if (nh.length < HOOK_PREFIX_FLOOR) return undefined; // too short → cannot discriminate let first: PublishedRecord | undefined; for (const r of records) { if (!normalize(r.body).startsWith(nh)) continue; if (r.published_date === post.date) return r; // exact date wins if (!first) first = r; } return first; } /** * Reconcile each silo-1 entry against the graph. `records` carry `body` (the join * key the body-less `PostGraphNode` lacks); `graph` carries each record's analytics * match. Pure. Tiers: `in-graph` (matched record HAS analytics) · `in-brain-only` * (matched record, no analytics) · `orphaned-in-state` (no matching record — the * coverage gap: created but never ingested). */ export function reconcileRecentPosts(args: { recentPosts: RecentPost[]; records: PublishedRecord[]; graph: PostGraphNode[]; }): ReconcileNode[] { const tierOf = new Map(args.graph.map((g) => [g.contentId, g.match.confidence])); return args.recentPosts.map((post) => { const rec = matchRecord(post, args.records); if (!rec) return { recentPost: post, tier: "orphaned-in-state" as const }; const hasAnalytics = (tierOf.get(rec.id) ?? "none") !== "none"; return { recentPost: post, tier: hasAnalytics ? ("in-graph" as const) : ("in-brain-only" as const), contentId: rec.id, }; }); } /** Count the coverage tiers. Pure. */ export function summarizeReconcile(nodes: ReconcileNode[]): ReconcileSummary { const sum: ReconcileSummary = { created: nodes.length, inGraph: 0, inBrainOnly: 0, orphaned: 0 }; for (const n of nodes) { if (n.tier === "in-graph") sum.inGraph++; else if (n.tier === "in-brain-only") sum.inBrainOnly++; else sum.orphaned++; } return sum; } /** * Resolve the runtime state file with the canonical precedence (the brain-side * copy of `hooks/scripts/data-root.mjs: getStateFile()` — `STATE_FILE` first, else * `~/.claude/linkedin-studio.local.md` with `HOME||USERPROFILE||homedir()`). This * is a NEW root-skew seam: the state file lives OUTSIDE the brain dataRoot * (`LINKEDIN_STUDIO_DATA`); if `STATE_FILE` is repointed the read follows it. */ function getStateFile(): string { const home = process.env.HOME || process.env.USERPROFILE || homedir(); return process.env.STATE_FILE || join(home, ".claude", "linkedin-studio.local.md"); } /** Read-only: load + parse silo 1 from the state file. Absent/unreadable → [] (fresh-clone safe). */ export function loadRecentPosts(): RecentPost[] { const file = getStateFile(); if (!existsSync(file)) return []; try { return parseRecentPosts(readFileSync(file, "utf8")); } catch { return []; // never crash on a malformed/locked state file } }