#!/usr/bin/env node /** * CLI for the persistent trend store (research-engine §5, foundation layer). * * node --import tsx src/cli.ts add --title "" --url "" --topics * [--source ] [--summary ""] [--store ] * node --import tsx src/cli.ts query --topics [--store ] [--json] * node --import tsx src/cli.ts list [--since ] [--limit ] [--store ] [--json] * node --import tsx src/cli.ts status [--store ] [--json] * node --import tsx src/cli.ts act|skip|reset --id [--store ] * echo '' | node --import tsx src/cli.ts normalize * echo '' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N] * echo '' | node --import tsx src/cli.ts capture [--store ] [--json] * node --import tsx src/cli.ts brief [--pillars ] [--fresh-days N] [--first-mover-days N] [--saturation-at N] * [--out ] [--no-mark] [--store ] [--json] * node --import tsx src/cli.ts schedule --pillars [--at HH:MM] [--fresh-days N] * [--platform auto|launchd|cron] [--install|--uninstall] [--store ] * * The capture agent (research-engine) folds freshly-polled trends into the store via * `capture` (the normalizing batch path: stdin → normalizeItem(s) → itemToInput → * addTrend) — which, when an item carries the agent's five judgment scores (RE-R3a), * persists an optional relevance `score` (the deterministically-computed composite + band, * one owner) first-sight on the record so the morning brief ranks on it — and reasons over * accumulated history via `query`/`list`. `brief` (RE-R2b) * renders a dated, pillar-ranked morning brief over the store to a Markdown file the * SessionStart hook surfaces; RE-R3d adds a DERIVED temporal overlay (first-mover up / saturated * down) as a within-tier ranking refinement, tunable via `--first-mover-days`/`--saturation-at`. * `add` is the MANUAL single-trend path (raw flags, no * normalization, publish-date-free). `act`/`skip`/`reset --id` set a trend's lifecycle status * (RE-R3b): the morning brief EXCLUDES acted/skipped and records each surfacing (per-day-idempotent * `surfacedCount`) so the loop stops re-surfacing handled work; a re-capture refreshes the score * (timing decays). The polling + relevance-scoring itself lives upstream; this is the deterministic store. * * `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 * `--json`, which stays an output toggle). `normalize` validates raw items into the * canonical envelope; `score` triages scored candidates (composite/band/threshold); * `capture` normalizes + folds each valid item into the store (persisting `publishedAt`), * reporting content-invalid items in the summary `errors[]`, never via the exit code. * * `schedule` (RE-R3c) is PRINT-FIRST: it emits a launchd plist (macOS) / cron line (Linux) firing the * DETERMINISTIC `brief` daily via the `run-daily.sh` headless wrapper, plus the exact activation command. * `--install` writes only the inert launchd plist FILE; the tool never runs `launchctl` or the cron table * (the operator runs the one printed command). No AI capture in the nightly run — that is a later slice. * * Exit code: 0 on success, 2 on usage error or a not-found id (act/skip/reset). A wrong --id is an * argument-class error; capture's content-invalid items stay in errors[] (never via the exit code). * `schedule` adds no new exit code: an autonomy install never RUNS the system mutation. */ import { readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; import { addTrend, defaultStorePath, history, loadStore, markSurfaced, newestCaptureDate, queryByTopic, saveStore, setStatus, } from "./store.js"; import type { TrendStatus } from "./types.js"; import { normalizeItem, normalizeItems, itemToInput } from "./item.js"; import { triage } from "./score.js"; import type { ScoreMode } from "./score.js"; import { rankForBrief, renderBrief, briefSummary, defaultBriefDir, surfacedIds, diffSurfaced, parseSurfacedFrontmatter, selectPriorBriefFile, } from "./brief.js"; import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js"; import type { ScheduleSpec } from "./schedule.js"; function parseFlags(args: string[]): Record { const out: Record = {}; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = args[i + 1]; if (next === undefined || next.startsWith("--")) { out[key] = "true"; } else { out[key] = next; i++; } } } return out; } function splitTopics(raw: string | undefined): string[] { if (!raw) return []; return raw .split(",") .map((t) => t.trim()) .filter((t) => t.length > 0); } function usage(msg: string): never { console.error(`error: ${msg}`); console.error( "usage:\n" + ' add --title "" --url "" --topics [--source ] [--summary ""] [--store ]\n' + " query --topics [--store ] [--json]\n" + " list [--since ] [--limit ] [--store ] [--json]\n" + " status [--store ] [--json]\n" + " act|skip|reset --id [--store ]\n" + " normalize < raw-item-or-batch.json\n" + " score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" + " capture [--store ] [--json] < raw-item-or-batch.json\n" + " brief [--pillars ] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out ] [--no-mark] [--store ] [--json]\n" + " schedule --pillars [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store ]", ); process.exit(2); } /** Read the full JSON payload from stdin, or exit 2 if it is empty/unparseable. */ function readStdinJson(): unknown { let raw = ""; try { raw = readFileSync(0, "utf8").trim(); } catch { raw = ""; } if (raw.length === 0) usage("expected a JSON payload on stdin"); try { return JSON.parse(raw); } catch { usage("stdin is not valid JSON"); } } function today(): string { return new Date().toISOString().slice(0, 10); } /** Whole days from one ISO date to another (floor); negative if `from` is later. */ function daysBetween(fromIso: string, toIso: string): number { return Math.floor((new Date(toIso).getTime() - new Date(fromIso).getTime()) / 86400000); } function main(): void { const [command, ...rest] = process.argv.slice(2); const flags = parseFlags(rest); const storePath = flags.store ?? defaultStorePath(); const asJson = flags.json === "true"; if (command === "add") { const title = flags.title; if (!title || title === "true") usage('add needs --title ""'); const url = flags.url; if (!url || url === "true") usage('add needs --url ""'); const topics = splitTopics(flags.topics); if (topics.length === 0) usage("add needs --topics "); const store = loadStore(storePath); const res = addTrend(store, { title, url, source: flags.source && flags.source !== "true" ? flags.source : "manual", capturedAt: today(), topics, ...(flags.summary && flags.summary !== "true" ? { summary: flags.summary } : {}), }); saveStore(storePath, res.store); if (res.added) { console.log(`Added: ${title}`); } else { console.log(`Duplicate — already in store${res.merged ? " (topics unioned)" : ""}: ${title}`); } console.log(`Store: ${storePath} (${res.store.trends.length} trends)`); return; } if (command === "query") { const topics = splitTopics(flags.topics); if (topics.length === 0) usage("query needs --topics "); const hits = queryByTopic(loadStore(storePath), topics); if (asJson) { console.log(JSON.stringify(hits, null, 2)); return; } if (hits.length === 0) { console.log(`No trends found for topics: ${topics.join(", ")}`); console.log("→ poll fresh signals (research engine), then `add` them."); return; } console.log(`${hits.length} trend(s) for: ${topics.join(", ")}`); for (const { trend, topicOverlap } of hits) { console.log(`\n · (overlap ${topicOverlap}) ${trend.title}`); console.log(` ${trend.url}`); console.log(` topics: ${trend.topics.join(", ")} — ${trend.source}, ${trend.capturedAt}`); if (trend.summary) console.log(` ${trend.summary}`); } return; } if (command === "list") { const opts: { since?: string; limit?: number } = {}; if (flags.since && flags.since !== "true") opts.since = flags.since; if (flags.limit && flags.limit !== "true") { const n = Number.parseInt(flags.limit, 10); if (Number.isNaN(n) || n < 0) usage("--limit must be a non-negative integer"); opts.limit = n; } const rows = history(loadStore(storePath), opts); if (asJson) { console.log(JSON.stringify(rows, null, 2)); return; } const scope = opts.since ? ` since ${opts.since}` : ""; console.log(`Store: ${storePath} — ${rows.length} trend(s)${scope}`); for (const t of rows) { console.log(` · ${t.capturedAt} ${t.title} {${t.topics.join(", ")}}`); } return; } if (command === "status") { const store = loadStore(storePath); const newest = newestCaptureDate(store); const daysStale = newest === null ? null : daysBetween(newest, today()); if (asJson) { console.log(JSON.stringify({ store: storePath, count: store.trends.length, newest, daysStale })); return; } console.log(`Store: ${storePath}`); console.log(` trends: ${store.trends.length}`); if (newest === null) { console.log(" newest: — (empty store; no captures yet)"); } else { console.log(` newest: ${newest} (${daysStale}d ago)`); } return; } if (command === "act" || command === "skip" || command === "reset") { const id = flags.id; if (!id || id === "true") usage(`${command} needs --id `); const status: TrendStatus = command === "act" ? "acted" : command === "skip" ? "skipped" : "new"; const store = loadStore(storePath); const res = setStatus(store, id, status); if (!res.found) { console.error(`error: no trend with id: ${id}`); process.exit(2); } saveStore(storePath, store); console.log(`Marked ${id} ${status}`); return; } if (command === "normalize") { const payload = readStdinJson(); const out = Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload); console.log(JSON.stringify(out, null, 2)); return; } if (command === "score") { const mode = flags.mode && flags.mode !== "true" ? flags.mode : "kortform"; if (mode !== "kortform" && mode !== "long-form") { usage('score --mode must be "kortform" or "long-form"'); } let threshold = 4.0; if (flags.threshold && flags.threshold !== "true") { const t = Number.parseFloat(flags.threshold); if (Number.isNaN(t)) usage("--threshold must be a number"); threshold = t; } const payload = readStdinJson(); if (!Array.isArray(payload)) usage("score expects a JSON array of scored candidates on stdin"); try { const result = triage(payload as Array<{ scores: Record }>, { mode: mode as ScoreMode, threshold, }); console.log(JSON.stringify(result, null, 2)); } catch (e) { usage(`scoring failed: ${(e as Error).message}`); } return; } if (command === "capture") { const payload = readStdinJson(); // exits 2 on empty/unparseable stdin const raw = Array.isArray(payload) ? payload : [payload]; const { items, errors } = normalizeItems(raw); const store = loadStore(storePath); // Tally derived from AddResult {added, merged} (no `duplicates` field): a fold is // `added` (new), else `merged` (existing gained topics and/or a refreshed score), else a plain `duplicate`. let added = 0; let merged = 0; let duplicates = 0; for (const item of items) { const res = addTrend(store, itemToInput(item, today())); if (res.added) added++; else if (res.merged) merged++; else duplicates++; } saveStore(storePath, store); if (asJson) { console.log(JSON.stringify({ added, duplicates, merged, errors }, null, 2)); return; } console.log( `Captured into ${storePath}: ${added} added, ${merged} merged, ` + `${duplicates} duplicate, ${errors.length} invalid (${store.trends.length} total)`, ); 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; } // RE-R3d temporal-overlay thresholds (defaults mirror brief.ts's RankOptions defaults). let firstMoverDays = 2; if (flags["first-mover-days"] && flags["first-mover-days"] !== "true") { const n = Number.parseInt(flags["first-mover-days"], 10); if (Number.isNaN(n) || n < 0) usage("--first-mover-days must be a non-negative integer"); firstMoverDays = n; } let saturationAt = 3; if (flags["saturation-at"] && flags["saturation-at"] !== "true") { const n = Number.parseInt(flags["saturation-at"], 10); if (Number.isNaN(n) || n < 1) usage("--saturation-at must be a positive integer"); saturationAt = 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 store = loadStore(storePath); // hoisted: also needed for the surfacing write below const ranking = rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt }); // RE-R3e: discover the most recent prior brief in outDir and diff today's surfaced cohort // against its `surfaced:` line. Any fs error (no dir, unreadable file) degrades to the // empty-prior (first-brief) path. The dir read lives HERE (the edge) — brief.ts stays fs-free. const todayIds = surfacedIds(ranking); let priorIds: string[] = []; let priorDate: string | null = null; try { if (existsSync(outDir)) { const priorFile = selectPriorBriefFile(readdirSync(outDir), day); if (priorFile) { priorIds = parseSurfacedFrontmatter(readFileSync(join(outDir, priorFile), "utf8")); priorDate = priorFile.slice(0, 10); } } } catch { priorIds = []; priorDate = null; } const diff = diffSurfaced(todayIds, priorIds, priorDate); const md = renderBrief(ranking, diff); const path = join(outDir, `${day}.md`); mkdirSync(outDir, { recursive: true }); writeFileSync(path, md, "utf8"); // RE-R3b: record surfacing on the store AFTER the pure render (per-day idempotent), unless --no-mark. // The handled (acted/skipped) records were filtered from the ranking but remain in `store`, so the // resave preserves them; only the surfaced ids' surfacedCount/lastSurfacedAt change. const mark = flags["no-mark"] !== "true"; const marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0; if (mark) saveStore(storePath, store); const summary = briefSummary(ranking, diff); // SAME source the frontmatter carries (RE-R3e: incl. the delta marker) if (asJson) { console.log( JSON.stringify( { path, date: ranking.today, totals: ranking.totals, summary, marked, diff: { priorDate: diff.priorDate, added: diff.added.length, carried: diff.carried.length, dropped: diff.dropped.length, }, }, null, 2, ), ); return; } const deltaNote = diff.added.length > 0 && diff.priorDate !== null ? `, ${diff.added.length} nye siden sist` : ""; console.log( `Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)${deltaNote}`, ); return; } if (command === "schedule") { // RE-R3c — print-first autonomous trigger. Emits (or installs) a launchd plist / cron line firing // the DETERMINISTIC brief daily via run-daily.sh; never runs launchctl or the cron table (C2). const pillars = splitTopics(flags.pillars); if (pillars.length === 0) usage("schedule needs --pillars "); const at = flags.at && flags.at !== "true" ? flags.at : "07:00"; const [hStr, mStr] = at.split(":"); const hour = Number.parseInt(hStr, 10); const minute = Number.parseInt(mStr ?? "", 10); if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { usage("--at must be HH:MM (00:00-23:59)"); } 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; } const platformFlag = flags.platform && flags.platform !== "true" ? flags.platform : "auto"; if (platformFlag !== "auto" && platformFlag !== "launchd" && platformFlag !== "cron") { usage("--platform must be auto|launchd|cron"); } const platform: "launchd" | "cron" = platformFlag === "auto" ? process.platform === "darwin" ? "launchd" : "cron" : (platformFlag as "launchd" | "cron"); // Resolve every absolute path FROM THE RUNTIME (never hard-coded → domain-general). const here = dirname(fileURLToPath(import.meta.url)); // .../scripts/trends/src const wrapperPath = join(here, "..", "run-daily.sh"); const workingDir = join(here, ".."); const nodeBin = process.execPath; // logPath is anchored to the DATA ROOT (dirname(defaultStorePath())), NOT the --store override, so a // custom --store never splits the plist StandardOutPath from the wrapper's own cron.log (sibling of // morning-brief/, matching brief.ts defaultBriefDir's dirname(defaultStorePath()) idiom). const logPath = join(dirname(defaultStorePath()), "cron.log"); const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); // env is canonical: always NODE_BIN + a resolved-absolute data root — pins the scheduled run to the // install-time root AND removes the wrapper's $HOME-unset `set -u` edge under a profile-less env. const env: Record = { NODE_BIN: nodeBin, LINKEDIN_STUDIO_DATA: root }; // The wrapper hard-codes the `brief` subcommand → the baked args carry NO leading "brief". const args = ["--pillars", pillars.join(","), "--fresh-days", String(freshDays)]; if (flags.store && flags.store !== "true") args.push("--store", storePath); const label = defaultLabel(); const spec: ScheduleSpec = { platform, label, nodeBin, wrapperPath, args, hour, minute, logPath, workingDir, env }; const plistTarget = join(homedir(), "Library", "LaunchAgents", `${label}.plist`); if (flags.uninstall === "true") { console.log(uninstallInstructions(spec, platform === "launchd" ? plistTarget : undefined)); if (platform === "launchd" && existsSync(plistTarget)) rmSync(plistTarget); return; } if (flags.install === "true") { if (platform === "launchd") { mkdirSync(dirname(plistTarget), { recursive: true }); writeFileSync(plistTarget, launchdPlist(spec), "utf8"); console.log(plistTarget); console.log(installInstructions(spec, plistTarget)); } else { console.log(crontabLine(spec)); console.log(installInstructions(spec)); } return; } // default / --print — emit the artifact + the activation command. No fs. console.log(platform === "launchd" ? launchdPlist(spec) : crontabLine(spec)); console.log(installInstructions(spec, platform === "launchd" ? plistTarget : undefined)); return; } usage(command ? `unknown command: ${command}` : "no command given"); } main();