# 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" | 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 ] [--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 ] [--no-mark] [--store ] [--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 # wrote about it node --import tsx src/cli.ts skip --id # decided to pass on it node --import tsx src/cli.ts reset --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 ] ``` 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 `· ()` 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 ` exit= ` 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: ` 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 ()` 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 ```