Adopt the agents/ architecture proven on medellin.co (reference impl): - Move the content engine to a top-level agents/ dir: orchestrators, prompts, config, run.sh, admin console, shared libs. All content-pipeline literals repointed (config paths, scripts, admin, LLM-facing prompts/image.md string, configure.mjs, new-site.sh, astroagent tokenFile, .gitignore runtime block). - Every script carries a parseable @agent-manifest header: name, title, class (content|operational|runtime|plumbing), trigger, model, prompts, skills (MCP), tools, reads/writes tables. 5 content agents + 3 plumbing scripts. - New agents/catalog.mjs generates the catalog from the headers: agents/AGENTS.md (human, grouped by class) + agents/agents.json (machine manifest — a clone diffs it against a source to find missing tools/tables/MCP before running). configure.mjs regenerates the catalog on every identity stamp. No DB table, no watcher. - config.json gains paths.stateDir/newsDir; publish-tick, write-daily, and news-radar read them instead of hardcoding. - Full cut: content-pipeline/ deleted (the seed has no live crons, so no hybrid period needed). Docs updated (AGENTS.md structure + pipeline section, README paths). Clones migrating from content-pipeline/: see medellin.co's .memory/handoffs/agents-directory-migration.md for the cutover playbook (one cron set active at a time; migrate drafts/state after repointing cron). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMQeUnUrAeexcZ7P2Hxa6G
128 lines
4.9 KiB
JavaScript
128 lines
4.9 KiB
JavaScript
#!/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();
|
|
}
|