/** * The dated morning-brief layer (research-engine §5, RE-R2b — the visible layer). * * Pure read-only view over the persistent trend store: rank the accumulated, * publish-dated signals against the user's content pillars (overlap), filter to a * freshness window, and render a dated Markdown artifact a later session surfaces. * No fs, no clock, no AI, no network — `today` and `pillars` are injected by the * caller (the CLI edge), exactly like the store's `capturedAt`. Determinism is the * contract: same (store, pillars, today, freshDays) -> byte-identical output. * * Ranking uses ONLY persisted fields (pillar overlap + publishedAt/capturedAt * recency). A persisted relevance/saturation score, an autonomous trigger, and a * seen-log freshness model are all later slices (R3); this module ships the * deterministic read the surfacing needs and nothing more. */ import { join, dirname } from "node:path"; import { defaultStorePath, effectiveStatus } from "./store.js"; import type { TrendStore, TrendRecord } from "./types.js"; /** The morning-brief artifact's own format version (distinct from the store's SCHEMA_VERSION). */ export const BRIEF_SCHEMA_VERSION = 2; /** * The live temporal overlay (RE-R3d): two derived signals — first-mover (recent AND * never surfaced on a prior day) and saturation (surfaced on >= saturationAt prior * days). DERIVED at brief time from already-persisted fields (publishedAt/capturedAt * -> ageDays, surfacedCount); never stored (no SCHEMA_VERSION bump), so it can never go * stale. Mirrors ageDays: a function of the record + today, computed per rank, not persisted. */ export type TemporalTier = "first-mover" | "neutral" | "warming" | "saturated"; export interface TemporalSignal { /** recent AND never surfaced on a prior day — "you'd be early". */ firstMover: boolean; /** prior-day surfacings (surfacedCount ?? 0) — the self-exposure level saturation reads. */ surfacings: number; /** the ordinal class (best -> worst opportunity). */ tier: TemporalTier; /** descending sort rank: first-mover 3 > neutral 2 > warming 1 > saturated 0. */ rank: number; } /** One ranked trend in the brief, with its pillar overlap + freshness. */ export interface BriefEntry { trend: TrendRecord; /** How many of the user's pillars the trend's topics matched. */ overlap: number; /** The matched pillar names, in pillar order, original case preserved. */ matchedPillars: string[]; /** publishedAt ?? capturedAt — the date freshness + ordering use. */ effectiveDate: string; /** Whole days from effectiveDate to the injected `today` (negative if future). */ ageDays: number; /** The live temporal overlay (RE-R3d) — derived per rank from ageDays + surfacedCount. */ temporal: TemporalSignal; } /** The full ranking the brief renders from. */ export interface BriefRanking { today: string; freshDays: number; totals: { trends: number; matched: number; fresh: number }; /** overlap >= 2 AND fresh. */ topMatches: BriefEntry[]; /** overlap === 1 AND fresh. */ singleMatches: BriefEntry[]; /** overlap >= 1 AND NOT fresh. */ olderMatched: BriefEntry[]; /** * The "I produksjon" board (N6, A1-7): records the operator has pulled out of the work queue — * status `selected` (valgt, in progress) or `acted` (skrevet, done). Pillar-independent (already * chosen), so they bypass the overlap filter entirely; sorted selected-before-acted, then title/url. */ inProduction: TrendRecord[]; } export interface RankOptions { /** Freshness window in days (effectiveDate within N days of today). Default 7. */ freshDays?: number; /** first-mover recency window in days (ageDays <= N AND unsurfaced). Default 2 (RE-R3d). */ firstMoverDays?: number; /** surfacedCount at/above which a trend is "saturated". Default 3 (RE-R3d). */ saturationAt?: number; } /** * Whole days from `effectiveDate` to `today` (floor). Local to this module — NOT * imported from cli.ts's daysBetween: cli.ts imports brief.ts, so importing back * would invert the dependency direction. brief.ts stays a leaf the CLI composes. */ function ageDaysBetween(effectiveDate: string, today: string): number { return Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000); } /** * The live temporal overlay (RE-R3d): classify a trend's opportunity window from its age + * self-exposure. Pure — every input is injected. first-mover = recent AND never surfaced on a * prior day (the `ageDays >= 0` guard keeps a future-dated glitch out of "act now"); saturated = * surfaced on >= saturationAt prior days; warming = surfaced 1..at-1; neutral = the no-signal * baseline. `at` is clamped to >= 1 so a stray `saturationAt 0` cannot mark every trend saturated. */ export function temporalSignal( ageDays: number, surfacedCount: number | undefined, opts: { firstMoverDays: number; saturationAt: number }, ): TemporalSignal { const surfacings = surfacedCount ?? 0; const at = Math.max(1, opts.saturationAt); const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0; const tier: TemporalTier = firstMover ? "first-mover" : surfacings >= at ? "saturated" : surfacings >= 1 ? "warming" : "neutral"; const rank = tier === "first-mover" ? 3 : tier === "neutral" ? 2 : tier === "warming" ? 1 : 0; return { tier, firstMover, surfacings, rank }; } /** * Rank the store against the user's pillars. Off-pillar trends (overlap 0) are * dropped; the rest bucket into top (>=2 & fresh), single (==1 & fresh), and older * (>=1 & stale). Each bucket is a TOTAL order: overlap desc, then effectiveDate * desc (freshest first), then title asc, then url asc — so the output is fixed * regardless of store insertion order. */ export function rankForBrief( store: TrendStore, pillars: string[], today: string, opts: RankOptions = {}, ): BriefRanking { const freshDays = opts.freshDays ?? 7; const firstMoverDays = opts.firstMoverDays ?? 2; const saturationAt = opts.saturationAt ?? 3; const wantedLower = pillars.map((p) => p.toLowerCase()); const entries: BriefEntry[] = []; const inProduction: TrendRecord[] = []; for (const trend of store.trends) { const st = effectiveStatus(trend); // N6 (A1-7): selected/acted are "in production" — collected for their own board, out of the queue. if (st === "selected" || st === "acted") { inProduction.push(trend); continue; } // RE-R3b (A3): skipped is handled — drop from the work queue (the brief is a queue, not an archive). if (st !== "new") continue; const have = new Set(trend.topics.map((t) => t.toLowerCase())); const matchedPillars: string[] = []; for (let i = 0; i < pillars.length; i++) { if (have.has(wantedLower[i])) matchedPillars.push(pillars[i]); } const overlap = matchedPillars.length; if (overlap === 0) continue; // off-pillar noise const effectiveDate = trend.publishedAt ?? trend.capturedAt; const ageDays = ageDaysBetween(effectiveDate, today); // Prior-DAY surfacings: the seen-log count EXCLUDING today. The brief renders BEFORE the CLI // records today's surfacing, so on the first run of a day surfacedCount is already prior-day — // but a same-day RE-RUN loads a count that already includes today (lastSurfacedAt === today), // so subtract it back out. This keeps a same-day re-render byte-identical (RE-R3c SC7) and makes // "first-mover" (prior surfacings === 0) stable across the render → mark step. const surfaced = trend.surfacedCount ?? 0; const priorSurfacings = trend.lastSurfacedAt === today ? Math.max(0, surfaced - 1) : surfaced; entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays, temporal: temporalSignal(ageDays, priorSurfacings, { firstMoverDays, saturationAt }), }); } // Composite is the PRIMARY within-bucket key (RE-R3a / D2): a higher persisted relevance // composite sorts first; an unscored record uses the sentinel -1 (composite is a weighted // sum of [1,10] dims, so it is always >= 1.0 — -1 sorts unscored last and subtracts // cleanly, where -Infinity - -Infinity = NaN would corrupt the comparator). Buckets are // unchanged; composite only re-orders WITHIN a bucket. // RE-R3d inserts the temporal-overlay rank (first-mover↑ / saturated↓) AFTER overlap and // BEFORE effectiveDate: composite + overlap stay primary, and the overlay refines recency // (a coarse temporal class) ahead of the raw effectiveDate it sits in front of. The existing // overlap → temporal → effectiveDate → title → url chain still gives a total order (the // (title,url) pair is the unique dedupe id; rank is a small integer, no NaN risk). const cmp = (a: BriefEntry, b: BriefEntry): number => (b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) || b.overlap - a.overlap || b.temporal.rank - a.temporal.rank || b.effectiveDate.localeCompare(a.effectiveDate) || a.trend.title.localeCompare(b.trend.title) || a.trend.url.localeCompare(b.trend.url); const isFresh = (e: BriefEntry): boolean => e.ageDays <= freshDays; const topMatches = entries.filter((e) => e.overlap >= 2 && isFresh(e)).sort(cmp); const singleMatches = entries.filter((e) => e.overlap === 1 && isFresh(e)).sort(cmp); const olderMatched = entries.filter((e) => !isFresh(e)).sort(cmp); // overlap>=1 (0 already excluded) // A total order for the production board: selected (in progress) before acted (done), then title, then url. const prodRank = (t: TrendRecord): number => (effectiveStatus(t) === "selected" ? 0 : 1); inProduction.sort( (a, b) => prodRank(a) - prodRank(b) || a.title.localeCompare(b.title) || a.url.localeCompare(b.url), ); return { today, freshDays, totals: { trends: store.trends.length, matched: entries.length, fresh: topMatches.length + singleMatches.length }, topMatches, singleMatches, olderMatched, inProduction, }; } /** * The single source of the brief's one-line summary — used by renderBrief (the * frontmatter line the SessionStart hook surfaces verbatim) AND the CLI --json. * One line, no embedded double-quote, no newline, so the hook's extractYaml regex * (^summary: *"?([^"\n]*)"?) captures it whole. */ export function briefSummary(ranking: BriefRanking, diff?: BriefDiff): string { // RE-R3e: the day-over-day delta marker — appended only when a prior brief exists AND // something is new (suppressed on the first brief / when nothing changed). Digits + ASCII // + a period: no `"`/`\n`, so the hook's ^summary: regex still captures the line whole. const delta = diff && diff.priorDate !== null && diff.added.length > 0 ? ` ${diff.added.length} nye siden sist.` : ""; const fresh = ranking.totals.fresh; if (fresh > 0) { const top = ranking.topMatches[0] ?? ranking.singleMatches[0]; const pillar = top.matchedPillars[0]; // Band only (no mode) — the mode stays a body-entry detail to keep the one-line headline clean. const band = top.trend.score ? ` · ${top.trend.score.priority}` : ""; // RE-R3d: surface the first-mover marker on the one-line headline ("act now, you're early"). const fm = top.temporal.firstMover ? " · 🥇 først ute" : ""; return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band}${fm} · ${top.ageDays}d).${delta}`; } return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).${delta}`; } /** ` · ()` when scored, else "" — the band+mode token shared by both renders (RE-R3a). */ function scoreToken(e: BriefEntry): string { const s = e.trend.score; return s ? ` · ${s.priority} (${s.mode})` : ""; } /** * The temporal-overlay badge (RE-R3d): promotes the R3b `· sett Nx` hint into a graded set. * `first-mover` → `· 🥇 først ute`; `saturated` → `· 🔁 mettet (Nx)`; `warming` → `· sett Nx` * ONLY at surfacings>=2 (preserving the EXACT R3b ≥2 display contract — the warming TIER still * demotes a surfaced-once trend in rank, but its badge stays suppressed); `neutral` → "". * The count is PRIOR-DAY (the brief renders before the CLI records today's surfacing). */ function temporalToken(e: BriefEntry): string { const t = e.temporal; if (t.tier === "first-mover") return " · 🥇 først ute"; if (t.tier === "saturated") return ` · 🔁 mettet (${t.surfacings}x)`; if (t.tier === "warming" && t.surfacings >= 2) return ` · sett ${t.surfacings}x`; return ""; } /** * The N6 proposal fields as detailed `- ` lines (top entries only) — each emitted ONLY when present, * so an unproposed trend renders exactly as before (backward-compat). Order is fixed for determinism. * The verdict + reader-grip share one line (the N7 band-cap gate's two inputs, shown together). */ function proposalLines(t: TrendRecord): string[] { const out: string[] = []; if (t.angle) out.push(`- 💡 Vinkel: ${t.angle}`); if (t.targetLevel) out.push(`- 🎚️ Målnivå: ${t.targetLevel}`); if (t.rationale) out.push(`- 🧭 Hvorfor nå: ${t.rationale}`); if (t.verdict || t.actionability) { const grip = t.actionability ? `${t.actionability.formulated ? "ja" : "nei"}${t.actionability.note ? ` («${t.actionability.note}»)` : ""}` : "—"; out.push(`- 🎯 Dom: ${t.verdict ?? "—"} · leser-grep: ${grip}`); } if (t.readerQuestion) out.push(`- ❓ Leserspørsmål: ${t.readerQuestion}`); if (t.painPoint) out.push(`- 🩹 Smertepunkt: ${t.painPoint}`); if (t.saturation) out.push(`- 🌡️ Metning: ${t.saturation}`); if (t.relatedIds && t.relatedIds.length > 0) out.push(`- ↔️ Relatert: ${t.relatedIds.join(", ")}`); return out; } /** The compact proposal token for single-line bullets: verdict + a reader-grip flag, when present. */ function proposalToken(t: TrendRecord): string { const bits: string[] = []; if (t.verdict) bits.push(`🎯 ${t.verdict}`); if (t.actionability) bits.push(`grep: ${t.actionability.formulated ? "ja" : "nei"}`); return bits.length > 0 ? ` · ${bits.join(" · ")}` : ""; } function renderTopEntry(e: BriefEntry, n: number): string[] { const lines = [ `### ${n}. ${e.trend.title}`, `- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``, ]; if (e.trend.summary) lines.push(`- ${e.trend.summary}`); lines.push(...proposalLines(e.trend)); lines.push(`- 🔗 ${e.trend.url}`); lines.push(""); return lines; } function renderBulletEntry(e: BriefEntry): string { return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)}${proposalToken(e.trend)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``; } /** * The "I produksjon" board (N6, A1-7): the selected/acted records rankForBrief set aside, each a * compact line tagged [valgt]/[skrevet]. Pure. The section header is always emitted (stable structure); * an empty board renders the explicit "_Ingen i produksjon._" marker. */ function renderInProduction(records: TrendRecord[]): string[] { const lines = ["## 🚧 I produksjon (valgt + skrevet)"]; if (records.length === 0) { lines.push("_Ingen i produksjon._", ""); return lines; } for (const t of records) { const tag = t.status === "acted" ? "skrevet" : "valgt"; const angle = t.angle ? ` · 💡 ${t.angle}` : ""; const verdict = t.verdict ? ` · 🎯 ${t.verdict}` : ""; lines.push(`- [${tag}] **${t.title}**${angle}${verdict} · \`${t.id}\``); } lines.push(""); return lines; } /** * Render the full dated Markdown artifact: YAML frontmatter (date, the shared * summary, store stats, ranking descriptor, schemaVersion) + a three-section body. * All three section headers are always emitted (stable structure → determinism). */ export function renderBrief( ranking: BriefRanking, diff: BriefDiff = { priorDate: null, added: [], carried: [], dropped: [] }, ): string { const { totals } = ranking; const lines: string[] = []; lines.push("---"); lines.push(`date: ${ranking.today}`); lines.push(`summary: ${briefSummary(ranking, diff)}`); lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`); lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped/selected (selected+acted shown in I produksjon)`); // RE-R3e: the set of ids this brief showed — the record the NEXT day's diff reads. Always // emitted (even blank for an empty store); independent of --no-mark (a property of the render). lines.push(`surfaced: ${surfacedIds(ranking).join(",")}`); lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`); lines.push("---"); lines.push(""); lines.push(`# Morgen-brief — ${ranking.today}`); lines.push(""); lines.push( `**${totals.fresh} ferske signaler** (publisert ≤${ranking.freshDays} dager) matcher temaene dine, av ${totals.trends} i lager.`, ); lines.push(""); // RE-R3e: the day-over-day delta leads, then the full ranked list below. lines.push(diff.priorDate !== null ? `## 🆕 Nytt siden sist (${diff.priorDate})` : "## 🆕 Nytt siden sist"); if (diff.priorDate === null) { lines.push(diff.added.length > 0 ? "_Første brief — alt nedenfor er nytt._" : "_Første brief._", ""); } else if (diff.added.length === 0) { lines.push( `_Ingenting nytt siden ${diff.priorDate}._`, `_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, "", ); } else { const byId = new Map( [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched].map((e) => [e.trend.id, e]), ); for (const id of diff.added) { const e = byId.get(id); if (e) lines.push(renderBulletEntry(e)); } lines.push(`_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, ""); } lines.push("## 🎯 Topp-treff (2+ pillarer)"); if (ranking.topMatches.length === 0) { lines.push("_Ingen i dag._", ""); } else { ranking.topMatches.forEach((e, i) => lines.push(...renderTopEntry(e, i + 1))); } lines.push("## 📌 Enkelt-treff (1 pillar)"); if (ranking.singleMatches.length === 0) lines.push("_Ingen i dag._"); else ranking.singleMatches.forEach((e) => lines.push(renderBulletEntry(e))); lines.push(""); lines.push(`## 💤 Eldre i lager (matcher, men >${ranking.freshDays}d) — ${ranking.olderMatched.length} stk`); ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e))); lines.push(""); // N6 (A1-7): the production board — where a triaged candidate lives once it leaves the queue. lines.push(...renderInProduction(ranking.inProduction)); lines.push("---"); lines.push("_Neste steg: /linkedin:react · /linkedin:post · /linkedin:newsletter_"); return lines.join("\n") + "\n"; } /** * The ids of the entries renderBrief actually shows: topMatches ∪ singleMatches ∪ the first 5 * olderMatched (mirroring the render's .slice(0,5)). The brief CLI feeds these to markSurfaced so * the seen-log records exactly what the operator saw. Pure (RE-R3b). */ export function surfacedIds(ranking: BriefRanking): string[] { return [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id); } /** * RE-R3e — the day-over-day diff of a brief's surfaced cohort against the most recent * prior brief. `priorDate` null ⇒ no prior (the first brief / a fresh data dir). * `added`/`carried`/`dropped` are the three DISJOINT partitions of the symmetric set * difference (an id is in exactly one), each order-stable. Pure: the id lists + the * prior date are injected by the CLI edge (no fs/clock here — like `today`/`pillars`). */ export interface BriefDiff { priorDate: string | null; added: string[]; carried: string[]; dropped: string[]; } /** The symmetric set difference of today's surfaced ids against the prior brief's. Pure (no fs/clock). */ export function diffSurfaced(currentIds: string[], priorIds: string[], priorDate: string | null): BriefDiff { const prior = new Set(priorIds); const cur = new Set(currentIds); return { priorDate, added: currentIds.filter((id) => !prior.has(id)), carried: currentIds.filter((id) => prior.has(id)), dropped: priorIds.filter((id) => !cur.has(id)), }; } /** * Extract a brief's `surfaced:` id list from its full text via one line-anchored regex * (the hook's `extractYaml` idiom). Absent / blank / malformed → [] (a pre-R3e or * hand-edited brief degrades to "empty prior"); never throws. Ids are comma-free hex. */ export function parseSurfacedFrontmatter(md: string): string[] { const m = md.match(/^surfaced: *([^\n]*)/m); if (!m) return []; return m[1] .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); } /** * The most recent prior brief's filename: the lexicographically greatest `YYYY-MM-DD.md` * strictly < `${today}.md` (ISO dates sort = date order), else null. Excludes today + any * future-dated file, so a same-day re-run diffs against the true previous day (determinism). */ export function selectPriorBriefFile(filenames: string[], today: string): string | null { const todayFile = `${today}.md`; return ( filenames .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f) && f < todayFile) .sort() .pop() ?? null ); } /** * Default brief directory under the per-user data dir, DERIVED from * defaultStorePath() so root resolution lives in exactly one place: * /trends/trends.json -> /trends/morning-brief. Colocated with the * store the brief reads. Pure path computation, no fs. */ export function defaultBriefDir(): string { return join(dirname(defaultStorePath()), "morning-brief"); }