linkedin-studio/scripts/trends/src/brief.ts
Kjell Tore Guttormsen 65b60337fe feat(linkedin-studio): N6 — forslags-lag i trends (angle/targetLevel/rationale/relatedIds + selected + --ids + F7/F9-felt actionability/verdict/readerQuestion/painPoint/saturation) [skip-docs]
Artikkelforslaget blir en persistert entitet (steg 2, A1-4) og godkjenningen får
et hjem (steg 3, A1-7) — før dette genererte agenten vinkel/begrunnelse som ble kastet.

Åtte additivt-valgfrie felt på TrendRecord/TrendInput/TrendItem:
- Forslag (A1-4/A1-5): angle · targetLevel (fritekst, brukerdefinert spenn — aldri enum) ·
  rationale · relatedIds[] (flerkilde).
- F7 leser-side: actionability {formulated, note?} (N7-bånd-cap-gaten leser `formulated`) ·
  verdict BÆRENDE/STØTTE/NYHET (lukket mekanisme-vokab).
- F9 leser-side: readerQuestion · painPoint · saturation (N7.5-sveipet fyller dem).

Ingen schema-bump: feltene er additivt-valgfrie, ingen record trenger migrering →
SCHEMA_VERSION forblir 4 (loadStore Math.max håndterer det). First-sight-persistering
(re-capture unionerer topics + re-scorer kun; klobrer aldri en triagert vinkel).

Validering: typede felt (verdict/actionability) feiler hardt ved malformert input;
fritekst/id-liste normaliseres lempelig (summary/topics-idiomet).

Livssyklus: TrendStatus += "selected" → new→selected→acted|skipped. Ny select-verb +
--ids-batch (act/skip/reset/select); partiell suksess = exit 0 + miss-rapport, all-miss = exit 2.
setStatusMany(): ren batch-mutasjon, per-id found/notFound.

Brief: forslagsfeltene rendres per kandidat (detaljert i topp-treff, kompakt token i bullets) +
ny «🚧 I produksjon»-seksjon (selected=valgt + acted=skrevet), pillar-uavhengig, deterministisk
sortert. selected/acted forlater arbeidskøen men vises i produksjons-boardet.

commands/trends.md Step 5 oppgradert: triage → Velg (select) / Skip, batchet per verb (--ids).

Trends-suite 245→266 (ny floor, +21 N6-tester). To eksisterende tester oppdatert for den
endrede brief-kontrakten (acted vises nå i I produksjon; ranking-descriptoren ekskluderer selected).
tsc rent. Roundtrip bevist: capture m/ alle felt → query → brief rendrer feltene → select → I produksjon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7SQpXJpBSNvpWaTNq1kWZ
2026-07-21 09:09:51 +02:00

480 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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}`;
}
/** ` · <priority> (<mode>)` 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 <url> · /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:
* <root>/trends/trends.json -> <root>/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");
}