#!/usr/bin/env node /** * @agent-manifest * name: news-radar * title: News Radar * class: content * trigger: manual / cron-capable (maintains news-queue.json for the writer) * description: Discover timely niche news and maintain news-queue.json; the daily writer drains it first. * model: research=claude-opus-4-8 * prompt: prompts/news-radar.system.md * skills: - * tools: lib/claude.mjs * reads: - * writes: - * created: 2026-07-04 * @end */ // News radar: discover timely Medellín food news/events and maintain news-queue.json. // Usage: node agents/scripts/news-radar.mjs import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs"; import { dirname, resolve, join } from "node:path"; import { fileURLToPath } from "node:url"; import { runClaude } from "./lib/claude.mjs"; import { loadJson, todayInTz, addDays } from "./lib/util.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const PIPELINE = resolve(HERE, ".."); const cfg = loadJson(join(PIPELINE, "config.json")); const root = cfg.paths.projectRoot; const queuePath = join(root, cfg.paths.newsDir, "news-queue.json"); const scanPath = join(root, cfg.paths.newsDir, "news-scan.json"); const loadArr = (p) => (existsSync(p) ? loadJson(p) : []); const publishedSlugs = () => { const d = join(root, cfg.paths.blogContentDir); return existsSync(d) ? readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name) : []; }; const calendarSlugs = () => { const p = join(root, cfg.paths.calendar); return existsSync(p) ? loadJson(p).map((e) => e.slug) : []; }; /** Scan for fresh news and merge into news-queue.json. Returns the merged queue. */ export async function runNewsRadar() { if (!cfg.news?.enabled) { console.log("[news] news.enabled is false — skipping."); return loadArr(queuePath); } const today = todayInTz(cfg.editorial.timezone); const recencyDays = cfg.news.recencyDays ?? 5; const maxPerScan = cfg.news.maxPerScan ?? 8; const existing = loadArr(queuePath); const known = new Set([...existing.map((i) => i.slug), ...publishedSlugs(), ...calendarSlugs()]); const prompt = `Find timely Medellín food/restaurant news to publish about now. - Today: ${today} - Prefer items from the last ~2-3 weeks. Return at most ${maxPerScan} items (fewer is fine; [] if nothing fresh). - Write the JSON array to: ${scanPath} - Do NOT reuse any of these existing slugs: ${[...known].join(", ") || "(none)"} - Existing published posts: ${join(root, cfg.paths.blogContentDir)} Follow the output contract in your system prompt exactly.`; await runClaude({ prompt, systemPromptFile: join(PIPELINE, "prompts/news-radar.system.md"), model: cfg.models.research, allowedTools: "Read Glob Grep WebSearch Write", addDirs: [root], cwd: root, logFile: join(root, cfg.paths.logsDir, `news-${today}.log`), }); const scanned = loadArr(scanPath); // Prune: drop stale FRESH items (older than recency window); keep used items for dedup history. const cutoff = addDays(today, -recencyDays); const kept = existing.filter((i) => i.status === "used" || (i.discovered || today) >= cutoff); const keptSlugs = new Set(kept.map((i) => i.slug)); const dedupe = new Set([...keptSlugs, ...publishedSlugs(), ...calendarSlugs()]); let added = 0; for (const item of scanned) { if (!item?.slug || dedupe.has(item.slug)) continue; kept.push({ ...item, discovered: item.discovered || today, status: "fresh" }); dedupe.add(item.slug); added++; } // Newest + hottest first. kept.sort((a, b) => (b.freshnessScore ?? 0) - (a.freshnessScore ?? 0) || (b.discovered || "").localeCompare(a.discovered || "")); writeFileSync(queuePath, JSON.stringify(kept, null, 2)); const fresh = kept.filter((i) => i.status === "fresh").length; console.log(`[news] scanned ${scanned.length}, added ${added}, queue now ${kept.length} (${fresh} fresh).`); return kept; } /** The freshest unused news item within the recency window, or null. */ export function pickFreshNews(cfg2 = cfg) { if (!cfg2.news?.enabled || !existsSync(queuePath)) return null; const today = todayInTz(cfg2.editorial.timezone); const cutoff = addDays(today, -(cfg2.news.recencyDays ?? 5)); const fresh = loadJson(queuePath) .filter((i) => i.status === "fresh" && (i.discovered || today) >= cutoff) .sort((a, b) => (b.freshnessScore ?? 0) - (a.freshnessScore ?? 0) || (b.discovered || "").localeCompare(a.discovered || "")); return fresh[0] || null; } /** Mark a news item used (after it's been drafted). */ export function markNewsUsed(slug) { if (!existsSync(queuePath)) return; const q = loadJson(queuePath); const item = q.find((i) => i.slug === slug); if (item) { item.status = "used"; writeFileSync(queuePath, JSON.stringify(q, null, 2)); } } // CLI if (import.meta.url === `file://${process.argv[1]}`) { await runNewsRadar(); }