seedproject-web/agents/scripts/write-daily.mjs

216 lines
9.4 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
feat: content-pipeline/ → agents/ — formalize the agent system in the seed 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
2026-07-11 20:33:48 +00:00
/**
* @agent-manifest
* name: writer
* title: Daily Blog Writer
* class: content
* trigger: cron:run.sh write-daily.mjs(daily)
* description: Draft one article (MDX + cover + sources) today's news first, else next evergreen calendar topic.
* model: writer=claude-sonnet-4-6
* prompt: prompts/writer.system.md, prompts/image.md
* skills: mcp__claude_ai_Higgsfield
* tools: lib/claude.mjs, lib/calendar.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// Daily writer: draft one article (MDX + cover.jpg + sources.json) into drafts/<slug>/.
// Selection order: today's news (news radar) first → else the next evergreen calendar topic.
// Refills the evergreen backlog on demand when it runs low. All calendar writes are atomic.
feat: content-pipeline/ → agents/ — formalize the agent system in the seed 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
2026-07-11 20:33:48 +00:00
// Usage: node agents/scripts/write-daily.mjs [slug]
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runClaude } from "./lib/claude.mjs";
import { loadJson, todayInTz } from "./lib/util.mjs";
import { readCalendar, updateEntry, addEntry } from "./lib/calendar.mjs";
import { runSeoReview } from "./seo-review.mjs";
import { runRevise } from "./revise.mjs";
import { runNewsRadar, pickFreshNews, markNewsUsed } from "./news-radar.mjs";
import { runResearch } from "./research.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
if (cfg.author.slug === "REPLACE_ME") {
console.error("[write-daily] config.author is not set. Add the real author first.");
process.exit(1);
}
const calendarPath = join(root, cfg.paths.calendar);
const today = todayInTz(cfg.editorial.timezone);
feat: content-pipeline/ → agents/ — formalize the agent system in the seed 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
2026-07-11 20:33:48 +00:00
const stateDir = join(root, cfg.paths.stateDir);
const argSlug = process.argv[2];
let entry;
if (argSlug) {
entry = readCalendar(calendarPath).find((e) => e.slug === argSlug);
if (!entry) {
console.error(`[write-daily] slug not found in calendar: ${argSlug}`);
process.exit(1);
}
} else {
// 0) User suggestions (from the admin dashboard) take top priority.
const suggestion = readCalendar(calendarPath)
.filter((e) => e.suggested && e.status === "planned")
.sort((a, b) => (a.date || "").localeCompare(b.date || ""))[0];
if (suggestion) {
entry = suggestion;
console.log(`[write-daily] suggestion-first: "${entry.workingTitle}"`);
} else {
mkdirSync(stateDir, { recursive: true });
// 1) Ensure today's news scan ran (guarded once/day so the cadence doesn't matter).
if (cfg.news?.enabled) {
const newsStatePath = join(stateDir, `news-${today}.json`);
if (!existsSync(newsStatePath)) {
try {
await runNewsRadar();
} catch (e) {
console.log(`[write-daily] news radar error (continuing): ${e.message}`);
}
writeFileSync(newsStatePath, JSON.stringify({ scanned: today }, null, 2));
}
}
// 2) Demand-driven evergreen refill when the backlog is low (guarded once/day).
const threshold = cfg.editorial.refillThreshold ?? 7;
const plannedCount = readCalendar(calendarPath).filter((e) => e.status === "planned").length;
const refillStatePath = join(stateDir, `research-${today}.json`);
if (plannedCount < threshold && !existsSync(refillStatePath)) {
console.log(`[write-daily] evergreen backlog low (${plannedCount} < ${threshold}) — refilling…`);
try {
const added = await runResearch({ count: cfg.editorial.refillCount ?? 14 });
console.log(`[write-daily] refill added ${added} topics.`);
} catch (e) {
console.log(`[write-daily] refill error (continuing): ${e.message}`);
}
writeFileSync(refillStatePath, JSON.stringify({ refilled: today }, null, 2));
}
// 3) News-first selection; inject the chosen news item as a calendar entry. Else evergreen.
const news = pickFreshNews(cfg);
if (news) {
entry = {
date: today,
slug: news.slug,
workingTitle: news.workingTitle,
type: news.type || "news-roundup",
primaryKeyword: news.primaryKeyword,
secondaryKeywords: news.secondaryKeywords || [],
searchIntent: news.searchIntent || "informational",
audienceAngle: news.newsHook || "",
sourceHints: news.sourceLinks || [],
eeatAngle: "Timely, sourced news — cite every claim.",
news: true,
freshnessScore: news.freshnessScore ?? 3,
status: "planned",
};
await addEntry(calendarPath, entry);
markNewsUsed(news.slug);
console.log(`[write-daily] news-first: "${entry.workingTitle}" (freshness ${entry.freshnessScore})`);
} else {
const cal = readCalendar(calendarPath);
const planned = cal.filter((e) => e.status === "planned");
const due = planned.filter((e) => e.date <= today).sort((a, b) => a.date.localeCompare(b.date));
entry = due[0] || planned.sort((a, b) => a.date.localeCompare(b.date))[0];
}
}
}
if (!entry) {
console.log("[write-daily] Nothing left to write — no fresh news and no planned topics.");
process.exit(0);
}
const draftDir = join(root, cfg.paths.draftsDir, entry.slug);
mkdirSync(draftDir, { recursive: true });
const [wMin, wMax] = cfg.editorial.wordCounts[entry.type] || [800, 1200];
const blogDir = join(root, cfg.paths.blogContentDir);
const logFile = join(root, cfg.paths.logsDir, `write-${entry.slug}.log`);
const prompt = `Write today's Comiida article from this calendar entry:
${JSON.stringify({ ...entry, draft: undefined, instructions: undefined }, null, 2)}
- Today's date: ${today}
- Author slug to byline (exact): ${cfg.author.slug}
- Word count range for type "${entry.type}": ${wMin}${wMax} words
- Draft output directory (write index.mdx, cover.jpg, sources.json here): ${draftDir}
- Existing published posts to read for internal links: ${blogDir}
- Image: use the Higgsfield MCP (preferred model "${cfg.image.preferredModel}", aspect ratio
feat: content-pipeline/ → agents/ — formalize the agent system in the seed 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
2026-07-11 20:33:48 +00:00
~${cfg.image.aspectRatio}). Read agents/prompts/image.md and follow that flow
(recommend model generate_image job_status sync curl the result URL).
Save the final image to: ${join(draftDir, "cover.jpg")}.
${
entry.authorFirsthand
? `\nAUTHOR FIRST-HAND PIECE: this is ${cfg.author.name}'s own draft, written from genuine first-hand experience. Follow the "Author first-hand pieces" EXCEPTION in your system prompt — keep the FIRST-PERSON voice and the author's honest opinions (do not neutralize them), and only fact-check/cite objective specifics (addresses, opening dates, prices, hours). SCOPE: the article is about the venues and points the author actually names — cover those; do NOT pad the piece with generic restaurants the author didn't mention just to hit a word count. Aim for the LOWER end of the word-count range; a tight, genuine ~8001000 words beats bloated filler.\n`
: ""
}${
entry.instructions
? `\nEDITOR INSTRUCTIONS (from the person who suggested this — follow them carefully):\n${entry.instructions}\n`
: ""
}${
entry.draft
? `\nEDITOR-PROVIDED DRAFT — use this as the basis for the article. Keep its intent and key points, but fact-check every claim, add real citations, improve structure/SEO, and expand it to meet the contract:\n"""\n${entry.draft}\n"""\n`
: ""
}
Follow the EEAT/SEO contract in your system prompt exactly.`;
await updateEntry(calendarPath, entry.slug, { status: "drafting" });
console.log(`[write-daily] drafting "${entry.workingTitle}" (${entry.type}) → ${draftDir}`);
const res = await runClaude({
prompt,
systemPromptFile: join(PIPELINE, "prompts/writer.system.md"),
model: cfg.models.writer,
// Higgsfield is configured globally via claude.ai (reachable headless) — no local --mcp-config.
allowedTools: "Read Write Edit Glob Grep WebSearch Bash mcp__claude_ai_Higgsfield",
addDirs: [root],
cwd: root,
logFile,
});
console.log(`[write-daily] agent: ${res.result || "(no text)"}`);
const hasMdx = existsSync(join(draftDir, "index.mdx"));
const hasCover = existsSync(join(draftDir, "cover.jpg"));
const finalStatus = hasMdx ? (hasCover ? "drafted" : "drafted-no-image") : "draft-failed";
await updateEntry(calendarPath, entry.slug, { status: finalStatus });
console.log(
`[write-daily] status=${finalStatus} mdx=${hasMdx} cover=${hasCover}\n` +
`[write-daily] review: ${draftDir} | log: ${logFile}`
);
if (!hasMdx) process.exit(1);
// SEO Specialist pass + auto-revise loop, so the approve/publish gate has a verdict.
try {
let audit = await runSeoReview(entry.slug, { published: false });
const minScore = cfg.seo?.minScore ?? 85;
const fails = (a) => !a || a.verdict === "fail" || (a.overall ?? 0) < minScore;
if (audit && cfg.seo?.autoRevise && fails(audit)) {
console.log(`[write-daily] SEO ${audit.verdict} ${audit.overall} < ${minScore} — auto-revising…`);
const { audit: revised, attempts } = await runRevise(entry.slug, {});
if (revised) audit = revised;
console.log(`[write-daily] auto-revise done after ${attempts} attempt(s): ${audit?.verdict} ${audit?.overall}`);
}
if (audit) {
await updateEntry(calendarPath, entry.slug, { seo: { verdict: audit.verdict, overall: audit.overall } });
console.log(
`[write-daily] SEO: ${audit.verdict} · ${audit.overall} · ${(audit.blocking || []).length} blocking`
);
} else {
console.log("[write-daily] SEO: audit could not be generated (continuing).");
}
} catch (e) {
console.log(`[write-daily] SEO review error (continuing): ${e.message}`);
}