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

View file

@ -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 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. 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 "<pillar1>,<pillar2>,<pillar3>"
```
`--pillars` is the user's pillar list (comma-separated, from their profile/config); the brief is
written to `<data-dir>/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** **Step 5: Compile digest**
- Format using output template below - Format using output template below

View file

@ -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 <data>/trends/morning-brief/<name> — 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');
});
});

View file

@ -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 — // 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). // 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. // 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'; 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 // Read REMEMBER.md for user session context
const rememberFile = join(getDataRoot(), 'REMEMBER.md'); const rememberFile = join(getDataRoot(), 'REMEMBER.md');
const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md'); const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md');

View file

@ -43,7 +43,11 @@
# with a non-vacuity self-test) in Section 16g; the trends-capture wiring guard (RE-R2a: # 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 # 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 # 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 818). # 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 818).
# #
# Usage: bash scripts/test-runner.sh # Usage: bash scripts/test-runner.sh
# bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`. # 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_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_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) 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 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)" pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)"
else else
@ -1115,6 +1119,57 @@ fi
echo "" 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) --- # --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
# The lint self-modifies its own checks, so a green run could mask a silently dropped # 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 # 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 # +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 # 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 + # 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 # 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 # 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 # 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. # clone). Runs last so TOTAL_CHECKS sees every prior check.
ASSERT_BASELINE_FLOOR=90 ASSERT_BASELINE_FLOOR=94
TOTAL_CHECKS=$((PASS + FAIL)) TOTAL_CHECKS=$((PASS + FAIL))
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"

View file

@ -69,11 +69,32 @@ node --import tsx src/cli.ts query --topics "agents,engineering" [--json]
# Time-scoped history — newest first, optionally windowed/capped # Time-scoped history — newest first, optionally windowed/capped
node --import tsx src/cli.ts list [--since 2026-06-01] [--limit 10] [--json] 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 <dir>] [--store <path>] [--json]
``` ```
Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend 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. 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 ## Tests
```bash ```bash

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 '<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 '<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] * 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 * The capture agent (research-engine) folds freshly-polled trends into the store via
* `capture` (the normalizing batch path: stdin normalizeItem(s) itemToInput * `capture` (the normalizing batch path: stdin normalizeItem(s) itemToInput
* addTrend), and reasons over accumulated history via `query`/`list`. `add` is the * addTrend), and reasons over accumulated history via `query`/`list`. `brief` (RE-R2b)
* MANUAL single-trend path (raw flags, no normalization, publish-date-free). The * renders a dated, pillar-ranked morning brief over the store to a Markdown file the
* polling + relevance-scoring itself lives upstream; this is the deterministic store. * 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 * `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 * 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). * 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 { import {
addTrend, addTrend,
@ -41,6 +45,7 @@ import {
import { normalizeItem, normalizeItems, itemToInput } from "./item.js"; import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
import { triage } from "./score.js"; import { triage } from "./score.js";
import type { ScoreMode } from "./score.js"; import type { ScoreMode } from "./score.js";
import { rankForBrief, renderBrief, briefSummary, defaultBriefDir } from "./brief.js";
function parseFlags(args: string[]): Record<string, string> { function parseFlags(args: string[]): Record<string, string> {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
@ -78,7 +83,8 @@ function usage(msg: string): never {
" status [--store <path>] [--json]\n" + " status [--store <path>] [--json]\n" +
" normalize < raw-item-or-batch.json\n" + " normalize < raw-item-or-batch.json\n" +
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.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); process.exit(2);
} }
@ -262,6 +268,32 @@ function main(): void {
return; 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"); usage(command ? `unknown command: ${command}` : "no command given");
} }

View file

@ -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;
}
});
});

View file

@ -2,7 +2,7 @@ import { describe, test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; 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 { join } from "node:path";
import { tmpdir } from "node:os"; 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<string, string> = {}): { 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() = <LINKEDIN_STUDIO_DATA>/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 });
}
});
});