linkedin-studio/scripts/trends/README.md
Kjell Tore Guttormsen 3290e8fb7d feat(linkedin-studio): RE-R3f — /linkedin:trends --headless (MR-F5) [skip-docs]
Unattended discovery: the full poll->score->capture->brief loop can
now fire with no operator present (Sunday-morning discovery), gated
behind a new Step 0.5 contract. Fails fast instead of asking when the
profile is missing, skips triage unconditionally (candidates stay
pending), and refuses --demand --headless rather than silently
degrading.

Trigger mechanism is Claude Code's own Desktop Scheduled Tasks, not a
hand-rolled cron wrapper -- verified against Claude Code's headless
docs that a cron + `claude -p` wrapper loses observability/session-
resumption/error-recovery versus the first-party mechanism. The
fallback `claude -p` recipe is documented (README) for portability,
not built as repo code. No new source/test file; no schema change;
counts unchanged (30/20/29).

This opens the research-engine's own 2026-06-24 re-evaluation gate
against v1.0.0 product maturity for slice (e) -- surfaced to the
operator explicitly rather than built around or silently deferred.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2qHoS4FrkabuD1bCg8Nr2
2026-08-10 20:57:34 +02:00

255 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 110 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 110 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). This wrapper is deliberately unaware of the AI capture
step — that is a **different mechanism**, below (RE-R3f).
## Unattended AI discovery — `--headless` (RE-R3f, slice e)
The gap `run-daily.sh` leaves open on purpose: without new captures, the nightly brief is a
near-no-op. Slice (e) closes it by making the **actual discovery pass**`/linkedin:trends`'s
poll → score → capture → brief loop, including the trend-spotter agent's web research — safe to
fire with no operator present, so Sunday discovery can happen unattended.
**This is not a bash/cron wrapper.** `/linkedin:trends --headless` needs a full Claude Code agent
turn (it spawns the `trend-spotter` subagent), which only the `claude` binary itself can run — a
shell script cannot replicate that. Two mechanisms exist for firing it unattended:
- **Claude Code's Desktop Scheduled Tasks (recommended).** A first-party, local, persistent
scheduler purpose-built for exactly this — unlike a hand-rolled cron job, it survives restarts,
keeps session history, and handles auth without a standing `ANTHROPIC_API_KEY`. Set it up to run
`/linkedin:trends --headless [--mode long-form]` on your Sunday cadence.
- **A hand-rolled `claude -p` invocation** (e.g. from cron/launchd directly), for a machine where
Desktop Scheduled Tasks isn't available. The exact recipe, verified against Claude Code's own
headless docs:
```bash
claude -p "/linkedin:trends --headless" \
--bare --plugin-dir "<path to the installed linkedin-studio plugin>" \
--permission-mode dontAsk \
--allowedTools "Read,Bash,Task,WebSearch,WebFetch" \
--output-format json
```
`--bare` skips plugin auto-discovery for a faster/cleaner scripted start, so `--plugin-dir` is
required alongside it. `--bare` also means no OAuth/keychain session — set `ANTHROPIC_API_KEY` in
the environment. `--permission-mode dontAsk` auto-denies anything not in `--allowedTools`
(including `AskUserQuestion`, unconditionally) instead of blocking on a prompt — the command's own
**Step 0.5 contract** (`commands/trends.md`) is what makes that safe rather than a silent abort.
If the research subagent can run long, raise `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` (default
10-minute wait ceiling on a background subagent) or set it to `0`.
**Do not trust the exit code or the model's prose as the success signal** — Claude Code's own docs
are explicit that a `-p` run's process exit reflects the CLI process, not the pass's semantic
outcome. Verify the pass actually did something the same way Step 3 of the command already does:
`CLI status --json`'s capture delta and whether today's `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/
linkedin-studio}/trends/morning-brief/<date>.md` exists. `--output-format json` also reports
`total_cost_usd` per run — log it if you care about the standing cost of a weekly unattended
research pass.
**Scope note:** RE-R3f adds no new source/test files here — the entire mechanism is the
`commands/trends.md` `--headless` contract (a prompt-file change) plus this documentation. No
`schedule.ts`/`run-daily.sh` change; that wrapper stays what RE-R3c built (deterministic brief-only,
above), and remains a separate, valid path for a plain daily re-render with no new discovery.
## 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
```