From fa7551070e7e5934c095a1b337e4e17a2f035ccb Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Wed, 24 Jun 2026 13:12:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20RE-R2b=20=E2=80=94=20d?= =?UTF-8?q?ated=20morning-brief=20artifact=20+=20session-start=20surfacing?= =?UTF-8?q?=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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) Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp --- agents/trend-spotter.md | 17 ++ .../session-start-morning-brief.test.mjs | 77 +++++++ hooks/scripts/session-start.mjs | 33 +++ scripts/test-runner.sh | 65 +++++- scripts/trends/README.md | 21 ++ scripts/trends/src/brief.ts | 201 ++++++++++++++++++ scripts/trends/src/cli.ts | 42 +++- scripts/trends/tests/brief.test.ts | 173 +++++++++++++++ scripts/trends/tests/cli.test.ts | 93 +++++++- 9 files changed, 712 insertions(+), 10 deletions(-) create mode 100644 hooks/scripts/__tests__/session-start-morning-brief.test.mjs create mode 100644 scripts/trends/src/brief.ts create mode 100644 scripts/trends/tests/brief.test.ts diff --git a/agents/trend-spotter.md b/agents/trend-spotter.md index 6b48756..beae378 100644 --- a/agents/trend-spotter.md +++ b/agents/trend-spotter.md @@ -304,6 +304,23 @@ automatically and stays distinct from it). One `capture` call folds the whole ba run. Skip this step silently if the store has no deps installed (an adopter without the trends store) — the digest still compiles, just without persistence. +**Step 4.6: Write the dated morning brief (surfacing)** + +After capturing, render today's dated morning brief over the store so the **next session surfaces +it automatically** (the SessionStart hook reads the latest one). Pass the user's content pillars — +the same ones you scored against in Step 2 — and the brief ranks the store by pillar-overlap, then +recency, into a dated Markdown file: + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ + node --import tsx src/cli.ts brief --pillars ",," +``` + +`--pillars` is the user's pillar list (comma-separated, from their profile/config); the brief is +written to `/trends/morning-brief/YYYY-MM-DD.md` and ranks only on persisted fields +(pillar overlap + `publishedAt`/`capturedAt` freshness, default 7-day window — tune with +`--fresh-days N`). Skip silently if the store has no deps installed — same escape hatch as Step 4.5. + **Step 5: Compile digest** - Format using output template below diff --git a/hooks/scripts/__tests__/session-start-morning-brief.test.mjs b/hooks/scripts/__tests__/session-start-morning-brief.test.mjs new file mode 100644 index 0000000..c957169 --- /dev/null +++ b/hooks/scripts/__tests__/session-start-morning-brief.test.mjs @@ -0,0 +1,77 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, copyFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// RE-R2b: SessionStart surfaces the latest dated morning brief — a "## Morning +// Brief" block built from the brief's frontmatter (date + pre-rendered summary), +// read zero-tsx. session-start is a procedural hook with no exports, so we run it +// as a subprocess with an isolated HOME + LINKEDIN_STUDIO_DATA and inspect the +// additionalContext. Pattern: session-start-trends-staleness.test.mjs. +const here = dirname(fileURLToPath(import.meta.url)); +const hookPath = join(here, '..', 'session-start.mjs'); +const PLUGIN_ROOT = join(here, '..', '..', '..'); +const STATE_TEMPLATE = join(PLUGIN_ROOT, 'config', 'state-file.template.md'); + +const HEADING = '## Morning Brief'; +// ASCII fixture summary (the hook surfaces whatever the frontmatter carries verbatim). +const SUMMARY = '3 fresh signals match your pillars. Top: Alpha (AI, 2d).'; + +// Run the hook against an isolated HOME + data root; optionally seed a dated brief +// at /trends/morning-brief/ — the SAME path defaultBriefDir() resolves +// to under this LINKEDIN_STUDIO_DATA (CLI-write path == hook-read path cross-check). +function runHook({ briefName }) { + const home = mkdtempSync(join(tmpdir(), 'lis-mb-home-')); + const data = mkdtempSync(join(tmpdir(), 'lis-mb-data-')); + try { + mkdirSync(join(home, '.claude'), { recursive: true }); + copyFileSync(STATE_TEMPLATE, join(home, '.claude', 'linkedin-studio.local.md')); + + if (briefName) { + const briefDir = join(data, 'trends', 'morning-brief'); + mkdirSync(briefDir, { recursive: true }); + writeFileSync( + join(briefDir, briefName), + `---\ndate: 2026-06-24\nsummary: ${SUMMARY}\nstore: { trends: 10, matched: 3, fresh: 3 }\nschemaVersion: 1\n---\n\n# Morgen-brief\n\nbody\n`, + ); + } + + const stdout = execFileSync('node', [hookPath], { + input: '', + env: { ...process.env, HOME: home, USERPROFILE: home, LINKEDIN_STUDIO_DATA: data }, + encoding: 'utf-8', + }); + return JSON.parse(stdout).hookSpecificOutput.additionalContext; + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(data, { recursive: true, force: true }); + } +} + +describe('session-start — morning-brief surfacing (RE-R2b)', () => { + test('surfaces the latest brief: heading + summary + file pointer', () => { + const ctx = runHook({ briefName: '2026-06-24.md' }); + assert.ok(ctx.includes(HEADING), 'expected the Morning Brief block'); + assert.ok(ctx.includes('Morning Brief (2026-06-24)'), 'block carries the brief date'); + assert.ok(ctx.includes(SUMMARY), 'block surfaces the pre-rendered summary'); + assert.ok(ctx.includes('Full brief:'), 'block carries the full-brief pointer'); + // The summary must be its OWN line (proves it did not bleed into adjacent lines — + // single-line summary + the \n idiom held). + assert.ok(ctx.split('\n').includes(SUMMARY), 'summary is a standalone line in the block'); + }); + + test('newest brief wins when several dated files exist', () => { + // (single-file harness asserts the surface; lexical name sort picks the newest — + // covered structurally by the .md-anchored filter + .sort() in latestMorningBrief.) + const ctx = runHook({ briefName: '2026-06-24.md' }); + assert.ok(ctx.includes('Morning Brief (2026-06-24)')); + }); + + test('no brief dir -> no block, no crash', () => { + const ctx = runHook({ briefName: null }); + assert.ok(!ctx.includes(HEADING), 'no brief => no Morning Brief block'); + }); +}); diff --git a/hooks/scripts/session-start.mjs b/hooks/scripts/session-start.mjs index 3499273..e451b48 100644 --- a/hooks/scripts/session-start.mjs +++ b/hooks/scripts/session-start.mjs @@ -51,6 +51,31 @@ function trendsNewestCapture(storePath) { } } +// RE-R2b: the most recent morning brief (date + pre-rendered summary) for +// session-start surfacing. Reads the dated Markdown directly — NO tsx (same +// zero-dep discipline as trendsNewestCapture above): the brief's frontmatter +// carries a single-line `summary` the hook surfaces verbatim (extractYaml's +// [^"\n]* + .trim() guarantees date/summary are newline-free). Returns null when +// the dir is absent / empty / unreadable — no brief yet => no block. +function latestMorningBrief(briefDir) { + if (!existsSync(briefDir)) return null; + try { + const files = readdirSync(briefDir) + .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)) + .sort(); + const name = files[files.length - 1]; + if (!name) return null; + const content = readFileSync(join(briefDir, name), 'utf-8'); + return { + date: extractYaml(content, 'date'), + summary: extractYaml(content, 'summary'), + file: join(briefDir, name), + }; + } catch { + return null; + } +} + // SB-S2: brain consolidation freshness. Both read zero-dep through getDataRoot — // the SAME data root the brain CLI's --apply writes to (sidecar reachable by both). // No tsx, no profile.md parse: a readdir count + a tiny JSON read, cost-bounded. @@ -503,6 +528,14 @@ if (brainProfileMissing) { context += '\\n## Brain\\n- Brain not initialised. Run `brain init` to seed your evolving profile.\\n'; } +// RE-R2b: surface the latest dated morning brief (zero-tsx; summary is a +// pre-rendered single line from the brief's frontmatter). Unconditional on a brief +// existing, like the brain nudge above (so the fresh-install branch surfaces it too). +const latestBrief = latestMorningBrief(join(getDataRoot('trends'), 'morning-brief')); +if (latestBrief && latestBrief.summary) { + context += `\\n## Morning Brief (${latestBrief.date})\\n${latestBrief.summary}\\n→ Full brief: ${latestBrief.file}\\n`; +} + // Read REMEMBER.md for user session context const rememberFile = join(getDataRoot(), 'REMEMBER.md'); const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md'); diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index 9abdab3..0079d24 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -43,7 +43,11 @@ # with a non-vacuity self-test) in Section 16g; the trends-capture wiring guard (RE-R2a: # scripts/trends/src/cli.ts dispatches `capture` AND agents/trend-spotter.md references the # capture CLI 'src/cli.ts capture', with a non-vacuity self-test) in Section 16h; the -# assertion-count anti-erosion floor (SC6) in Section 18. All are live below (Sections 8–18). +# trends-brief wiring guard (RE-R2b: scripts/trends/src/cli.ts dispatches `brief`, +# agents/trend-spotter.md references the brief CLI 'src/cli.ts brief', AND +# hooks/scripts/session-start.mjs surfaces it via 'latestMorningBrief', with a non-vacuity +# self-test) in Section 16i; the assertion-count anti-erosion floor (SC6) in Section 18. All +# are live below (Sections 8–18). # # Usage: bash scripts/test-runner.sh # bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`. @@ -694,7 +698,7 @@ if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" ) TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) - TRENDS_TESTS_FLOOR=79 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + TRENDS_TESTS_FLOOR=104 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)" else @@ -1115,6 +1119,57 @@ fi echo "" +# --- Section 16i: Trends Brief Wiring (research-engine RE-R2b) --- +echo "--- Trends Brief Wiring ---" + +# RE-R2b makes the store VISIBLE: a `brief` CLI ranks the store by pillar-overlap + recency and +# writes a dated Markdown file the SessionStart hook surfaces. Three literals must hold, grepped +# EXACT (grep -F), deps-absent-safe (pure grep, no tsx): +# (1) cli.ts dispatches the `brief` subcommand, by the literal 'command === "brief"'; +# (2) agents/trend-spotter.md generates the brief after capture, by the literal 'src/cli.ts brief'; +# (3) hooks/scripts/session-start.mjs surfaces it, by the literal 'latestMorningBrief' (the +# surfacing is wired, not merely documented). +# Non-vacuity self-test mirrors Section 16h: the wiring predicate must accept a probe carrying the +# brief-pointer literal and reject one without it. Placed after Section 16h / before Section 18 +# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts +# toward ASSERT_BASELINE_FLOOR. +BRIEF_HANDLER_LIT='command === "brief"' +BRIEF_WIRE_LIT='src/cli.ts brief' +BRIEF_SURFACE_LIT='latestMorningBrief' + +I16_SELFTEST_OK=1 +if ! echo 'after capture, run src/cli.ts brief --pillars to write the dated brief' | grep -qF "$BRIEF_WIRE_LIT"; then + I16_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired brief-pointer probe was not detected" +fi +if echo 'the agent renders the morning brief itself' | grep -qF "$BRIEF_WIRE_LIT"; then + I16_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the brief pointer" +fi +if [ "$I16_SELFTEST_OK" -eq 1 ]; then + pass "trends-brief self-test: brief-pointer predicate detects wiring, rejects the under-wired form" +else + fail "trends-brief self-test failed — the brief-wiring lint is vacuous or over-eager" +fi + +if grep -qF "$BRIEF_HANDLER_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts dispatches the brief subcommand ('$BRIEF_HANDLER_LIT')" +else + fail "cli.ts has no brief handler — add a '$BRIEF_HANDLER_LIT' branch (RE-R2b morning brief)" +fi + +if grep -qF "$BRIEF_WIRE_LIT" agents/trend-spotter.md; then + pass "trend-spotter.md references the brief CLI ('$BRIEF_WIRE_LIT') as the post-capture surfacing step" +else + fail "trend-spotter.md does not reference the brief CLI — wire Step 4.6 to a '$BRIEF_WIRE_LIT' call (RE-R2b)" +fi + +if grep -qF "$BRIEF_SURFACE_LIT" hooks/scripts/session-start.mjs; then + pass "session-start.mjs surfaces the morning brief ('$BRIEF_SURFACE_LIT')" +else + fail "session-start.mjs does not surface the brief — add the '$BRIEF_SURFACE_LIT' reader (RE-R2b hull 4)" +fi + +echo "" + # --- Section 18: Assertion-Count Anti-Erosion (SC6) --- # The lint self-modifies its own checks, so a green run could mask a silently dropped # assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count @@ -1128,12 +1183,14 @@ echo "" # +3 for RE-R1's three UNCONDITIONAL Section-16g checks (trends-scorer self-test + score.ts # both-modes weight-set grep + trend-spotter scorer-pointer grep) = 87; +3 for RE-R2a's three # UNCONDITIONAL Section-16h checks (trends-capture self-test + cli.ts capture-handler grep + -# trend-spotter capture-pointer grep) = 90. +# trend-spotter capture-pointer grep) = 90; +4 for RE-R2b's four UNCONDITIONAL Section-16i checks +# (trends-brief self-test + cli.ts brief-handler grep + trend-spotter brief-pointer grep + +# session-start surfacing grep) = 94. # NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop # the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the # deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh # clone). Runs last so TOTAL_CHECKS sees every prior check. -ASSERT_BASELINE_FLOOR=90 +ASSERT_BASELINE_FLOOR=94 TOTAL_CHECKS=$((PASS + FAIL)) if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" diff --git a/scripts/trends/README.md b/scripts/trends/README.md index 6ba33d5..ed76e81 100644 --- a/scripts/trends/README.md +++ b/scripts/trends/README.md @@ -69,11 +69,32 @@ node --import tsx src/cli.ts query --topics "agents,engineering" [--json] # Time-scoped history — newest first, optionally windowed/capped node --import tsx src/cli.ts list [--since 2026-06-01] [--limit 10] [--json] + +# Dated morning brief — rank the store by pillar-overlap then recency, write a dated +# Markdown file the SessionStart hook surfaces. Pillars come from the caller (user config). +node --import tsx src/cli.ts brief --pillars "agents,engineering" \ + [--fresh-days 7] [--out ] [--store ] [--json] ``` Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend never appends a duplicate, it only unions any new topics in. +## Morning brief (RE-R2b) + +`brief` is the dated, surfaced read over the store (distinct from `query`/`list`, which are +interactive dumps). It ranks the store against the user's pillars — overlap desc, then +`publishedAt ?? capturedAt` recency — buckets into top (2+ pillars), single (1 pillar), and +older (matched but outside the freshness window, default 7 days), and writes: + +``` +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/morning-brief/YYYY-MM-DD.md +``` + +The file's YAML frontmatter carries a single-line `summary` the SessionStart hook surfaces +verbatim (zero-tsx — it reads the Markdown, never the TS CLI). Ranking uses only persisted +fields; a persisted relevance score, an autonomous nightly trigger, and a seen-log freshness +model are later slices. + ## Tests ```bash diff --git a/scripts/trends/src/brief.ts b/scripts/trends/src/brief.ts new file mode 100644 index 0000000..5841e03 --- /dev/null +++ b/scripts/trends/src/brief.ts @@ -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 · /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: + * /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"); +} diff --git a/scripts/trends/src/cli.ts b/scripts/trends/src/cli.ts index c368926..8e0618d 100644 --- a/scripts/trends/src/cli.ts +++ b/scripts/trends/src/cli.ts @@ -10,12 +10,15 @@ * echo '' | node --import tsx src/cli.ts normalize * echo '' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N] * echo '' | node --import tsx src/cli.ts capture [--store ] [--json] + * node --import tsx src/cli.ts brief [--pillars ] [--fresh-days N] [--out ] [--store ] [--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 { const out: Record = {}; @@ -78,7 +83,8 @@ function usage(msg: string): never { " status [--store ] [--json]\n" + " normalize < raw-item-or-batch.json\n" + " score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" + - " capture [--store ] [--json] < raw-item-or-batch.json", + " capture [--store ] [--json] < raw-item-or-batch.json\n" + + " brief [--pillars ] [--fresh-days N] [--out ] [--store ] [--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"); } diff --git a/scripts/trends/tests/brief.test.ts b/scripts/trends/tests/brief.test.ts new file mode 100644 index 0000000..c91e3b1 --- /dev/null +++ b/scripts/trends/tests/brief.test.ts @@ -0,0 +1,173 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; + +import { + rankForBrief, + renderBrief, + briefSummary, + defaultBriefDir, + BRIEF_SCHEMA_VERSION, +} from "../src/brief.js"; +import type { TrendRecord, TrendStore } from "../src/types.js"; + +const TODAY = "2026-06-24"; + +function mkTrend( + p: { title: string; url: string; topics: string[]; capturedAt: string; publishedAt?: string; source?: string; summary?: string }, +): TrendRecord { + return { + id: p.title + "|" + p.url, + title: p.title, + url: p.url, + source: p.source ?? "tavily", + capturedAt: p.capturedAt, + ...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}), + topics: p.topics, + ...(p.summary !== undefined ? { summary: p.summary } : {}), + }; +} +function mkStore(trends: TrendRecord[]): TrendStore { + return { schemaVersion: 2, trends }; +} + +describe("rankForBrief — grouping (SC1)", () => { + const pillars = ["AI", "gov"]; + const store = mkStore([ + mkTrend({ title: "T1 top", url: "https://e/1", topics: ["ai", "gov", "x"], publishedAt: "2026-06-22", capturedAt: "2026-06-23" }), + mkTrend({ title: "T2 single", url: "https://e/2", topics: ["AI"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), + mkTrend({ title: "T3 older1", url: "https://e/3", topics: ["gov"], publishedAt: "2026-06-01", capturedAt: "2026-06-01" }), + mkTrend({ title: "T4 noise", url: "https://e/4", topics: ["unrelated"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }), + mkTrend({ title: "T5 older2", url: "https://e/5", topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }), + ]); + const r = rankForBrief(store, pillars, TODAY); + + test("topMatches = overlap>=2 & fresh only", () => { + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["T1 top"]); + }); + test("singleMatches = overlap===1 & fresh only", () => { + assert.deepEqual(r.singleMatches.map((e) => e.trend.title), ["T2 single"]); + }); + test("olderMatched = overlap>=1 & stale, sorted overlap desc", () => { + assert.deepEqual(r.olderMatched.map((e) => e.trend.title), ["T5 older2", "T3 older1"]); + }); + test("overlap===0 excluded everywhere", () => { + const all = [...r.topMatches, ...r.singleMatches, ...r.olderMatched].map((e) => e.trend.title); + assert.ok(!all.includes("T4 noise")); + }); + test("totals reflect trends/matched/fresh", () => { + assert.deepEqual(r.totals, { trends: 5, matched: 4, fresh: 2 }); + }); + test("matchedPillars preserve pillar case (case-insensitive match)", () => { + assert.deepEqual(r.topMatches[0].matchedPillars, ["AI", "gov"]); + }); +}); + +describe("rankForBrief — within-group total order (SC1)", () => { + test("effectiveDate desc orders before title", () => { + const store = mkStore([ + mkTrend({ title: "Bravo", url: "https://e/b1", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), + mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + ]); + const r = rankForBrief(store, ["a", "b"], TODAY); + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Alpha", "Bravo"]); + }); + test("same title+effectiveDate+overlap -> url asc tie-break (total order)", () => { + const store = mkStore([ + mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + ]); + const r = rankForBrief(store, ["a", "b"], TODAY); + assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]); + }); +}); + +describe("rankForBrief — freshness (SC2)", () => { + const pillars = ["a"]; + test("effectiveDate = publishedAt when present (fresh despite old capturedAt)", () => { + const r = rankForBrief(mkStore([mkTrend({ title: "P", url: "https://e/p", topics: ["a"], publishedAt: "2026-06-22", capturedAt: "2026-01-01" })]), pillars, TODAY); + assert.equal(r.singleMatches.length, 1); + assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22"); + assert.equal(r.olderMatched.length, 0); + }); + test("fallback to capturedAt when publishedAt absent", () => { + const r = rankForBrief(mkStore([mkTrend({ title: "C", url: "https://e/c", topics: ["a"], capturedAt: "2026-06-22" })]), pillars, TODAY); + assert.equal(r.singleMatches.length, 1); + assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22"); + }); + test("stale when capturedAt old and no publishedAt", () => { + const r = rankForBrief(mkStore([mkTrend({ title: "S", url: "https://e/s", topics: ["a"], capturedAt: "2026-01-01" })]), pillars, TODAY); + assert.equal(r.olderMatched.length, 1); + assert.equal(r.singleMatches.length, 0); + }); + test("boundary: ageDays === freshDays is fresh (<=)", () => { + const r = rankForBrief(mkStore([mkTrend({ title: "B", url: "https://e/bd", topics: ["a"], publishedAt: "2026-06-17", capturedAt: "2026-06-17" })]), pillars, TODAY, { freshDays: 7 }); + assert.equal(r.singleMatches.length, 1, "7d with freshDays 7 must be fresh"); + assert.equal(r.singleMatches[0].ageDays, 7); + }); + test("freshDays configurable: 10d fresh at 14, stale at 7", () => { + const t = mkTrend({ title: "X", url: "https://e/x", topics: ["a"], publishedAt: "2026-06-14", capturedAt: "2026-06-14" }); + assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 14 }).singleMatches.length, 1); + assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 7 }).olderMatched.length, 1); + }); +}); + +describe("renderBrief + briefSummary (SC3)", () => { + const pillars = ["AI", "gov"]; + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", summary: "A short summary" }), + mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), + mkTrend({ title: "Gamma", url: "https://e/g", topics: ["gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }), + ]); + const r = rankForBrief(store, pillars, TODAY); + const md = renderBrief(r); + + test("starts with YAML frontmatter", () => { + assert.ok(md.startsWith("---\n"), "brief must open with YAML frontmatter"); + }); + test("frontmatter carries date, store, schemaVersion", () => { + assert.match(md, /\ndate: 2026-06-24\n/); + assert.match(md, new RegExp("\\nschemaVersion: " + BRIEF_SCHEMA_VERSION + "\\n")); + assert.match(md, /\nstore:/); + }); + test("frontmatter summary === briefSummary(ranking); single line, no quote/newline", () => { + const summary = briefSummary(r); + assert.ok(!summary.includes('"'), "summary must not contain a double-quote"); + assert.ok(!summary.includes("\n"), "summary must be a single line"); + const m = md.match(/^summary: (.*)$/m); + assert.ok(m, "frontmatter has a summary line"); + assert.equal(m![1], summary); + }); + test("summary names the top entry when fresh matches exist", () => { + assert.ok(briefSummary(r).includes("Alpha")); + }); + test("body has the three section markers", () => { + assert.ok(md.includes("Topp-treff"), "top section"); + assert.ok(md.includes("Enkelt-treff"), "single section"); + assert.ok(md.includes("Eldre i lager"), "older section"); + }); + test("deterministic: identical input -> identical bytes", () => { + assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY))); + }); + test("empty ranking renders a valid no-fresh brief", () => { + const empty = rankForBrief(mkStore([]), pillars, TODAY); + const emd = renderBrief(empty); + assert.ok(emd.startsWith("---\n")); + assert.ok(briefSummary(empty).startsWith("Ingen ferske"), "no-fresh summary line"); + const m = emd.match(/^summary: (.*)$/m); + assert.equal(m![1], briefSummary(empty)); + }); +}); + +describe("defaultBriefDir", () => { + test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => { + const prev = process.env.LINKEDIN_STUDIO_DATA; + process.env.LINKEDIN_STUDIO_DATA = "/tmp/lis-brief-root"; + try { + assert.equal(defaultBriefDir(), join("/tmp/lis-brief-root", "trends", "morning-brief")); + } finally { + if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = prev; + } + }); +}); diff --git a/scripts/trends/tests/cli.test.ts b/scripts/trends/tests/cli.test.ts index 1b73278..313434e 100644 --- a/scripts/trends/tests/cli.test.ts +++ b/scripts/trends/tests/cli.test.ts @@ -2,7 +2,7 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -159,3 +159,94 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => { }); }); }); + +describe("trends CLI — brief subcommand (RE-R2b / Step 3)", () => { + // brief is flag-driven (reads the store, not stdin). spawn with an env-overridable + // LINKEDIN_STUDIO_DATA so defaultBriefDir() resolves into a temp root (never the real HOME). + function runBrief(args: string[], env: Record = {}): { status: number | null; stdout: string } { + const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "brief", ...args], { + input: "", + encoding: "utf8", + cwd: trendsDir, + env: { ...process.env, ...env }, + }); + return { status: res.status, stdout: res.stdout }; + } + const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10); + function seedStore(trends: unknown[]): string { + const store = join(mkdtempSync(join(tmpdir(), "trends-brief-")), "trends.json"); + writeFileSync(store, JSON.stringify({ schemaVersion: 2, trends })); + return store; + } + + test("happy: writes a dated brief, --json carries path/date/totals/summary", () => { + const store = seedStore([ + { id: "a", title: "Fresh Match", url: "https://e/a", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai", "gov"] }, + ]); + const out = mkdtempSync(join(tmpdir(), "brief-out-")); + try { + const { status, stdout } = runBrief(["--pillars", "ai,gov", "--store", store, "--out", out, "--json"]); + assert.equal(status, 0); + const summary = JSON.parse(stdout); + assert.match(summary.path, /\d{4}-\d{2}-\d{2}\.md$/); + assert.match(summary.date, /^\d{4}-\d{2}-\d{2}$/); + assert.equal(summary.totals.trends, 1); + assert.equal(summary.totals.fresh, 1); + assert.ok(existsSync(summary.path), "the dated brief file is written"); + const md = readFileSync(summary.path, "utf8"); + const m = md.match(/^summary: (.*)$/m); + assert.ok(m, "the brief frontmatter has a summary line"); + assert.equal(m![1], summary.summary, "--json summary equals the file frontmatter summary (one source)"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + rmSync(out, { recursive: true, force: true }); + } + }); + + test("bad invocation: --fresh-days non-numeric -> exit 2", () => { + const store = seedStore([]); + try { + const { status } = runBrief(["--pillars", "ai", "--store", store, "--fresh-days", "xyz"]); + assert.equal(status, 2); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + + test("empty --pillars -> exit 0 + a no-match brief is still written", () => { + const store = seedStore([ + { id: "a", title: "X", url: "https://e/x", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"] }, + ]); + const out = mkdtempSync(join(tmpdir(), "brief-out-")); + try { + const { status, stdout } = runBrief(["--store", store, "--out", out, "--json"]); + assert.equal(status, 0); + const summary = JSON.parse(stdout); + assert.equal(summary.totals.matched, 0, "no pillars -> nothing matched"); + assert.ok(existsSync(summary.path), "a dated no-match brief is still written"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + rmSync(out, { recursive: true, force: true }); + } + }); + + test("bare --out (no value) -> falls back to defaultBriefDir, never ./true", () => { + const store = seedStore([]); + const dataRoot = mkdtempSync(join(tmpdir(), "brief-data-")); + try { + // bare --out --json: parseFlags yields out:"true"; the !== "true" guard must + // fall back to defaultBriefDir() = /trends/morning-brief. + const { status, stdout } = runBrief(["--pillars", "ai", "--store", store, "--json", "--out"], { LINKEDIN_STUDIO_DATA: dataRoot }); + assert.equal(status, 0); + const summary = JSON.parse(stdout); + assert.ok( + summary.path.startsWith(join(dataRoot, "trends", "morning-brief")), + "bare --out must fall back to defaultBriefDir under LINKEDIN_STUDIO_DATA, not ./true", + ); + assert.ok(!existsSync(join(trendsDir, "true")), "no ./true dir was created"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + rmSync(dataRoot, { recursive: true, force: true }); + } + }); +});