#!/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] * 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] * * The capture agent (research-engine) folds freshly-polled trends into the store via * `capture` (the normalizing batch path: stdin → normalizeItem(s) → itemToInput → * addTrend), and reasons over accumulated history via `query`/`list`. `add` is the * MANUAL single-trend path (raw flags, no normalization, publish-date-free). 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. * * Exit code: 0 on success, 2 on usage error (incl. unparseable stdin / bad flag). */ import { readFileSync } from "node:fs"; import { addTrend, defaultStorePath, history, loadStore, newestCaptureDate, queryByTopic, saveStore, } from "./store.js"; import { normalizeItem, normalizeItems, itemToInput } from "./item.js"; import { triage } from "./score.js"; import type { ScoreMode } from "./score.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" + " 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", ); 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 === "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), 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; } usage(command ? `unknown command: ${command}` : "no command given"); } main();