# linkedin-trends-store

Persistent **trend store** — the foundation layer of the research engine
(retning §5). A topic-tagged, provenance-bearing inventory of trend signals
captured over time, so the engine accumulates **history** instead of starting
amnesiac each session.

Twin of [`scripts/specifics-bank`](../specifics-bank): same deterministic
store / dedup / query discipline, different dedupe key — a trend is identified by
its **normalized title+URL**, not by free-text content.

## Generic by architecture

Nothing niche-specific lives here. A `TrendRecord` carries free-form `topics`
tags and a free-form `source` string; *which* topics matter and *which* sources
to poll are decided upstream (config/profile + the capture agent), never
hard-coded in this module. The same store serves any niche.

## Data location

The store lives under the per-user data dir (M0 data-path convention), so trend
history survives plugin upgrades/reinstalls:

```
${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/trends.json
```

`LINKEDIN_STUDIO_DATA` overrides the root. No path is hard-coded in prose.

## Record shape (minimal generic core)

```ts
interface TrendRecord {
  id: string;          // sha256(normalized title+url).slice(0,12) — also the dedupe key
  title: string;       // headline, verbatim
  url: string;         // source URL, verbatim
  source: string;      // "tavily" | "websearch" | "manual" | <mcp-name>
  capturedAt: string;  // ISO-8601 date — when WE captured it
  publishedAt?: string;// optional source publish date (ISO-8601); distinct from capturedAt, first-sight, never back-filled
  topics: string[];    // query tags; unioned across re-captures
  summary?: string;    // optional, verbatim
  score?: TrendScore;      // persisted relevance (RE-R3a): { mode, dimensions, composite, priority } — REFRESHED on re-capture (RE-R3b, last-wins)
  status?: TrendStatus;    // lifecycle (RE-R3b): "new" | "acted" | "skipped"; absent ⇒ "new"; the brief excludes non-new
  surfacedCount?: number;  // seen-log (RE-R3b): distinct days surfaced in a brief; absent ⇒ 0; per-day idempotent
  lastSurfacedAt?: string; // seen-log (RE-R3b): ISO date of the most recent surfacing
}
```

`score` is the persisted relevance envelope (RE-R3a): a capture **item** carries the
agent's **judgment** — `{ mode, dimensions }` (the five 1–10 dimension scores) — and the
store turns that into the persisted `TrendScore` `{ mode, dimensions, composite, priority }`,
computing the composite + band once via the single scorer owner (`src/score.ts`). It is
**refreshed on re-capture** (RE-R3b, last-wins — the timing dimension decays, so the newer
judgment supersedes the stored one; `score` is the one mutable field, provenance stays
first-sight); the score-free `add` manual path omits it. The morning brief ranks each bucket
on `composite` first (schema v4).

The **lifecycle** fields (RE-R3b) are the trend's life after first capture: `status` is set by
the `act`/`skip`/`reset` verbs (a freshly-captured trend is implicitly `new`), and the seen-log
`surfacedCount`/`lastSurfacedAt` is recorded by `brief` (per-day idempotent) so the loop can avoid
re-surfacing handled work.

## CLI

```bash
# Capture freshly-polled trends — the NORMALIZING BATCH path (the research agent's path):
# raw items on stdin → validate+normalize each → dedupe on title+url → union topics on
# re-capture → persist the source's publishedAt → persist the relevance score (when carried).
# Content-invalid items (incl. a malformed/out-of-range score) are reported in the summary
# errors[], never fail the run; the summary is {added, duplicates, merged, errors}.
# An item's "score" carries the agent's judgment (mode + the five 1–10 dimensions); the store
# computes the composite + band and persists the full TrendScore first-sight.
echo '[{"source":"tavily","title":"Agentic workflows hit production",
        "url":"https://example.com/agentic","topics":["agents","engineering"],
        "publishedAt":"2026-06-20","summary":"Teams ship multi-step agents past the demo stage.",
        "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}]' \
  | node --import tsx src/cli.ts capture [--store <path>] [--json]

# Add a SINGLE trend MANUALLY — raw flags, no normalization, publish-date-free:
node --import tsx src/cli.ts add \
  --title "Agentic workflows hit production" \
  --url "https://example.com/agentic" \
  --topics "agents,engineering" --source tavily \
  --summary "Teams ship multi-step agents past the demo stage."

# Topic-scoped history — trends matching these topics, ranked by overlap then recency
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 composite then pillar-overlap then recency, write a
# dated Markdown file the SessionStart hook surfaces. Pillars come from the caller (user config).
# The brief EXCLUDES acted/skipped trends and RECORDS surfacing on the store (per-day idempotent)
# unless --no-mark. Pillars come from the caller (user config).
node --import tsx src/cli.ts brief --pillars "agents,engineering" \
  [--fresh-days 7] [--out <dir>] [--no-mark] [--store <path>] [--json]

# Lifecycle — mark a trend handled so the brief stops re-surfacing it (id shown in the brief / list --json):
node --import tsx src/cli.ts act   --id <id>   # wrote about it
node --import tsx src/cli.ts skip  --id <id>   # decided to pass on it
node --import tsx src/cli.ts reset --id <id>   # return it to the queue

# Autonomous trigger (RE-R3c) — emit/install a daily headless brief. PRINT-FIRST: the tool never runs
# launchctl or the cron table; --install writes only the inert launchd plist file. Deterministic
# (no AI capture — a later slice). Default 07:00; --platform auto → launchd on macOS, cron on Linux.
node --import tsx src/cli.ts schedule --pillars "agents,engineering" \
  [--at 07:00] [--fresh-days 7] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]
```

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). As of RE-R3a the brief ranks
each bucket on the persisted relevance **composite first** (then pillar-overlap, then recency);
a scored entry shows `· <priority> (<mode>)` and the summary names the top entry's band.

As of **RE-R3b** the brief is a **work queue**: it **excludes** `acted`/`skipped` trends, shows
each entry's `id` in backticks (copy-paste-ready for `act`/`skip --id`), flags a re-surfaced item
with `· sett Nx` (prior-day count, ≥2), and — unless `--no-mark` — **records surfacing** on the
store (`surfacedCount`/`lastSurfacedAt`, per-day idempotent) after the pure render.

## Autonomous trigger + headless entry (RE-R3c)

`schedule` makes the daily loop **closed**: it emits — print-first — a launchd plist (macOS) or cron
line (Linux) firing the brief every morning, plus the exact activation command. `--install` writes only
the **inert** launchd plist file under `~/Library/LaunchAgents/`; the tool **never** runs `launchctl`
or the cron table — you run the one printed command. `--uninstall` prints the removal recipe (and
removes the plist file if present).

Both schedulers invoke one tested headless wrapper, `run-daily.sh`, which runs the **deterministic**
brief from a scheduler's profile-less environment: it resolves node (baked `NODE_BIN`, else
`command -v`, else common locations), `cd`s into the package so `tsx` resolves, and appends one compact
line `<ISO-ts> exit=<code> <json>` to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log`.

The nightly run is **deterministic-brief-only (C1)**: it re-renders the brief from the current store
— freshness-aging drops stale trends, `surfacedCount` accumulates day-over-day — but does **not** poll
new sources. A double-fire on the same day is a safe no-op (RE-R3b per-day idempotency: byte-identical
`.md`, `surfacedCount` not double-counted). The autonomous AI capture step (poll → score → capture
before the brief) plugs into the documented seam in `run-daily.sh` as a later slice (e); a
brief-history diff is also a later slice.

## Temporal overlay (RE-R3d)

The brief applies a **derived temporal overlay** when it ranks — two signals computed at render time
from already-persisted fields (`publishedAt`/`capturedAt` → age, `surfacedCount` → self-exposure), so
**nothing new is stored** (`SCHEMA_VERSION` stays 4) and the signal can never go stale:

- **first-mover** — recent (`ageDays ≤ --first-mover-days`, default **2**) AND never surfaced on a
  prior day. Ranked up; badge `· 🥇 først ute`. A future-dated trend (`ageDays < 0`) is excluded.
- **saturation** — surfaced on `≥ --saturation-at` (default **3**) prior distinct days. Ranked down;
  badge `· 🔁 mettet (Nx)`. This is **self-surfacing** ("you keep seeing this") from OUR seen-log — not
  market coverage (that needs external polling, a later slice).
- **warming** (surfaced 1..at-1) keeps the RE-R3b `· sett Nx` badge, but **only at ≥2** (that badge
  contract is unchanged); **neutral** (no exposure signal) carries no badge.

Ranking integration: the relevance composite (RE-R3a) stays the **primary** sort key; the temporal
rank (first-mover↑ / saturated↓) is a new key inserted **after** pillar-overlap and **before** the
`effectiveDate` recency tiebreaker — so the overlay only re-orders *within* a (composite, overlap)
tier, never overriding relevance. Prior-day surfacings exclude today (via `lastSurfacedAt`), so a
same-day re-render is byte-identical. Tune per run with `--first-mover-days N` / `--saturation-at N`
(the scheduled nightly run uses the defaults).

## Brief history + diff (RE-R3e)

Each brief records the trend ids it showed into its own frontmatter — one `surfaced: <id-csv>` line
= `surfacedIds(ranking)` (the cohort the brief surfaced). This bumps the **artifact** schema
`BRIEF_SCHEMA_VERSION` **1 → 2**; the store's `SCHEMA_VERSION` stays **4** (no store field — the
membership lives in the artifact, the diff is derived at the CLI edge).

When a brief is written, the CLI discovers the most recent **prior** dated file
(`selectPriorBriefFile`: the greatest `YYYY-MM-DD.md` strictly `< today`, so a same-day re-run diffs
against the true previous day and stays byte-identical), parses its `surfaced:` line
(`parseSurfacedFrontmatter`, degrading to "empty prior" on any absent/blank/malformed/pre-R3e file),
and computes the symmetric set difference (`diffSurfaced`):

- **added** — in today, not in the prior brief: the headline "what's new", rendered with titles
  resolved from the ranking under a `## 🆕 Nytt siden sist (<prior-date>)` section that **leads** the
  ranked list.
- **carried** / **dropped** — in both / in the prior only: a one-line count (`N båret over, M ikke
  vist i dag`). Framed "ikke vist i dag" (not "resolved") — a count cannot prove why a trend left.

A ` N nye siden sist.` marker is appended to the one-line `summary:` the SessionStart hook surfaces,
so the delta shows **without opening the file** (no hook change). The three helpers are **pure**
(string/array in, value out); the directory + file reads live at the `cli.ts` edge, so `brief.ts`
stays fs-free. `--no-mark` is unaffected (it governs only the store seen-log; `surfaced:` always
records what the brief showed).

## Tests

```bash
cd scripts/trends
npm install
npm test     # deterministic store: normalize/id, load/save, dedup+union, query, history
npm run build
```
