feat(linkedin-studio): RE-R2b — dated morning-brief artifact + session-start surfacing [skip-docs]

The visible layer of R2. Pure brief.ts: rankForBrief (pillar-overlap -> recency over
the store; publishedAt ?? capturedAt freshness, 7d window; total-order sort), renderBrief
(dated Markdown + hook-surfaceable summary frontmatter), briefSummary (one summary source),
defaultBriefDir (derived from defaultStorePath). CLI `brief` writes
<data>/trends/morning-brief/YYYY-MM-DD.md; session-start surfaces the latest zero-tsx
(latestMorningBrief). Wired into trend-spotter Step 4.6 (scan->capture->brief->surfaced).
No store-schema/scoring change; no scheduler (R3).

25 new trends tests (21 brief.test + 4 cli brief, RED-first) + 3 hook tests (morning-brief
surfacing). trends 104/104 (floor 104), hook-suite 139/139, gate FAIL=0 (ASSERT floor 94,
Section 16i: cli brief-handler + trend-spotter brief-pointer + session-start surfacing
greps), tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 13:12:54 +02:00
commit fa7551070e
9 changed files with 712 additions and 10 deletions

201
scripts/trends/src/brief.ts Normal file
View file

@ -0,0 +1,201 @@
/**
* 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 } 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 = 1;
/** 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 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[];
}
export interface RankOptions {
/** Freshness window in days (effectiveDate within N days of today). Default 7. */
freshDays?: 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);
}
/**
* 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 wantedLower = pillars.map((p) => p.toLowerCase());
const entries: BriefEntry[] = [];
for (const trend of store.trends) {
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;
entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays: ageDaysBetween(effectiveDate, today) });
}
const cmp = (a: BriefEntry, b: BriefEntry): number =>
b.overlap - a.overlap ||
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)
return {
today,
freshDays,
totals: { trends: store.trends.length, matched: entries.length, fresh: topMatches.length + singleMatches.length },
topMatches,
singleMatches,
olderMatched,
};
}
/**
* 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): string {
const fresh = ranking.totals.fresh;
if (fresh > 0) {
const top = ranking.topMatches[0] ?? ranking.singleMatches[0];
const pillar = top.matchedPillars[0];
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar} · ${top.ageDays}d).`;
}
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`;
}
function renderTopEntry(e: BriefEntry, n: number): string[] {
const lines = [
`### ${n}. ${e.trend.title}`,
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d) · Pillarer: ${e.matchedPillars.join(", ")}`,
];
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
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) · 🔗 ${e.trend.url}`;
}
/**
* 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): string {
const { totals } = ranking;
const lines: string[] = [];
lines.push("---");
lines.push(`date: ${ranking.today}`);
lines.push(`summary: ${briefSummary(ranking)}`);
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
lines.push(`ranking: pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`);
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("");
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("");
lines.push("---");
lines.push("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_");
return lines.join("\n") + "\n";
}
/**
* 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");
}

View file

@ -10,12 +10,15 @@
* echo '<raw item|batch>' | node --import tsx src/cli.ts normalize
* echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N]
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--store <path>] [--json]
*
* The capture agent (research-engine) folds freshly-polled trends into the store via
* `capture` (the normalizing batch path: stdin normalizeItem(s) itemToInput
* addTrend), and reasons over accumulated history via `query`/`list`. `add` is the
* MANUAL single-trend path (raw flags, no normalization, publish-date-free). The
* polling + relevance-scoring itself lives upstream; this is the deterministic store.
* addTrend), and reasons over accumulated history via `query`/`list`. `brief` (RE-R2b)
* renders a dated, pillar-ranked morning brief over the store to a Markdown file the
* SessionStart hook surfaces. `add` is the MANUAL single-trend path (raw flags, no
* normalization, publish-date-free). The polling + relevance-scoring itself lives
* upstream; this is the deterministic store.
*
* `normalize` + `score` (RE-R1) and `capture` (RE-R2a) are the deterministic
* research-engine seam: all read their JSON PAYLOAD FROM STDIN (so they do not overload
@ -27,7 +30,8 @@
* Exit code: 0 on success, 2 on usage error (incl. unparseable stdin / bad flag).
*/
import { readFileSync } from "node:fs";
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
addTrend,
@ -41,6 +45,7 @@ import {
import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
import { triage } from "./score.js";
import type { ScoreMode } from "./score.js";
import { rankForBrief, renderBrief, briefSummary, defaultBriefDir } from "./brief.js";
function parseFlags(args: string[]): Record<string, string> {
const out: Record<string, string> = {};
@ -78,7 +83,8 @@ function usage(msg: string): never {
" status [--store <path>] [--json]\n" +
" normalize < raw-item-or-batch.json\n" +
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
" capture [--store <path>] [--json] < raw-item-or-batch.json",
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--store <path>] [--json]",
);
process.exit(2);
}
@ -262,6 +268,32 @@ function main(): void {
return;
}
if (command === "brief") {
const pillars = splitTopics(flags.pillars);
let freshDays = 7;
if (flags["fresh-days"] && flags["fresh-days"] !== "true") {
const n = Number.parseInt(flags["fresh-days"], 10);
if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer");
freshDays = n;
}
// A bare `--out` yields the string "true" (parseFlags); the guard falls back to
// defaultBriefDir() so it never writes to ./true.
const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir();
const day = today(); // one wall-clock read for both the ranking and the filename
const ranking = rankForBrief(loadStore(storePath), pillars, day, { freshDays });
const md = renderBrief(ranking);
const path = join(outDir, `${day}.md`);
mkdirSync(outDir, { recursive: true });
writeFileSync(path, md, "utf8");
const summary = briefSummary(ranking); // SAME source the frontmatter carries
if (asJson) {
console.log(JSON.stringify({ path, date: ranking.today, totals: ranking.totals, summary }, null, 2));
return;
}
console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh)`);
return;
}
usage(command ? `unknown command: ${command}` : "no command given");
}