linkedin-studio/scripts/trends/README.md
Kjell Tore Guttormsen 3276e44dbf feat(linkedin-studio): RE-R3c — autonomous trigger (scheduler + headless entry) [skip-docs]
Closes research-engine hulls (1) no autonomous trigger + (6) no headless entry.
Makes the daily research loop closed + headless: deterministic-brief-only (C1),
print-first (C2 — the tool never runs launchctl or the cron table; --install writes
only the inert launchd plist file).

- NEW scripts/trends/src/schedule.ts — pure string emitters (launchd plist + cron-line +
  install/uninstall instructions + defaultLabel). No clock/fs/env/AI; byte-deterministic.
- NEW scripts/trends/run-daily.sh — bash-3.2 headless wrapper: resolves node, cd's into the
  package so tsx resolves, logs via the data-path twin seam; runs the deterministic brief and
  appends one compact cron.log line per fire. The (e) AI-capture seam is documented, not built.
- EDIT cli.ts — schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
  [--platform auto|launchd|cron] [--install|--uninstall] [--store <p>]; print-first, no new
  exit code; logPath anchored to dirname(defaultStorePath()) (not the --store override).
- WIRE trend-spotter.md (one prose line) + README (scheduler + wrapper + the C1 boundary).
- Gate: TRENDS_TESTS_FLOOR 171->192, ASSERT_BASELINE_FLOOR 105->111, new UNCONDITIONAL
  Section 16l (6 deps-absent greps + non-vacuity self-test), header-enum + floor-history append.

TDD two-phase RED -> GREEN. trends 192/192, gate 126/0, hook-suite 139/0 (untouched), plutil
-lint OK. No schema change (SCHEMA_VERSION 4 / BRIEF_SCHEMA_VERSION 1). Counts 29/19/27 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 11:00:59 +02:00

162 lines
9 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). 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.
## Tests
```bash
cd scripts/trends
npm install
npm test # deterministic store: normalize/id, load/save, dedup+union, query, history
npm run build
```