nodebench-mcp 2.17.0 → 2.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/NODEBENCH_AGENTS.md +2 -2
  3. package/README.md +516 -82
  4. package/dist/__tests__/analytics.test.d.ts +11 -0
  5. package/dist/__tests__/analytics.test.js +546 -0
  6. package/dist/__tests__/analytics.test.js.map +1 -0
  7. package/dist/__tests__/dynamicLoading.test.d.ts +1 -0
  8. package/dist/__tests__/dynamicLoading.test.js +278 -0
  9. package/dist/__tests__/dynamicLoading.test.js.map +1 -0
  10. package/dist/__tests__/evalHarness.test.js +1 -1
  11. package/dist/__tests__/evalHarness.test.js.map +1 -1
  12. package/dist/__tests__/helpers/answerMatch.js +22 -22
  13. package/dist/__tests__/presetRealWorldBench.test.js +9 -0
  14. package/dist/__tests__/presetRealWorldBench.test.js.map +1 -1
  15. package/dist/__tests__/tools.test.js +1 -1
  16. package/dist/__tests__/toolsetGatingEval.test.js +9 -1
  17. package/dist/__tests__/toolsetGatingEval.test.js.map +1 -1
  18. package/dist/analytics/index.d.ts +10 -0
  19. package/dist/analytics/index.js +11 -0
  20. package/dist/analytics/index.js.map +1 -0
  21. package/dist/analytics/projectDetector.d.ts +19 -0
  22. package/dist/analytics/projectDetector.js +259 -0
  23. package/dist/analytics/projectDetector.js.map +1 -0
  24. package/dist/analytics/schema.d.ts +57 -0
  25. package/dist/analytics/schema.js +157 -0
  26. package/dist/analytics/schema.js.map +1 -0
  27. package/dist/analytics/smartPreset.d.ts +63 -0
  28. package/dist/analytics/smartPreset.js +300 -0
  29. package/dist/analytics/smartPreset.js.map +1 -0
  30. package/dist/analytics/toolTracker.d.ts +59 -0
  31. package/dist/analytics/toolTracker.js +163 -0
  32. package/dist/analytics/toolTracker.js.map +1 -0
  33. package/dist/analytics/usageStats.d.ts +64 -0
  34. package/dist/analytics/usageStats.js +252 -0
  35. package/dist/analytics/usageStats.js.map +1 -0
  36. package/dist/db.js +359 -321
  37. package/dist/db.js.map +1 -1
  38. package/dist/index.d.ts +2 -1
  39. package/dist/index.js +652 -89
  40. package/dist/index.js.map +1 -1
  41. package/dist/tools/architectTools.js +13 -13
  42. package/dist/tools/critterTools.js +14 -14
  43. package/dist/tools/parallelAgentTools.js +176 -176
  44. package/dist/tools/patternTools.js +11 -11
  45. package/dist/tools/progressiveDiscoveryTools.d.ts +5 -1
  46. package/dist/tools/progressiveDiscoveryTools.js +111 -19
  47. package/dist/tools/progressiveDiscoveryTools.js.map +1 -1
  48. package/dist/tools/researchWritingTools.js +42 -42
  49. package/dist/tools/rssTools.js +396 -396
  50. package/dist/tools/toolRegistry.d.ts +17 -0
  51. package/dist/tools/toolRegistry.js +65 -17
  52. package/dist/tools/toolRegistry.js.map +1 -1
  53. package/dist/tools/voiceBridgeTools.js +498 -498
  54. package/dist/toolsetRegistry.d.ts +10 -0
  55. package/dist/toolsetRegistry.js +84 -0
  56. package/dist/toolsetRegistry.js.map +1 -0
  57. package/package.json +4 -4
@@ -8,25 +8,25 @@ import { getDb } from "../db.js";
8
8
  // ── SQLite schema ────────────────────────────────────────────────────────────
9
9
  function ensureRssTables() {
10
10
  const db = getDb();
11
- db.exec(`
12
- CREATE TABLE IF NOT EXISTS rss_sources (
13
- id INTEGER PRIMARY KEY AUTOINCREMENT,
14
- url TEXT NOT NULL UNIQUE,
15
- name TEXT,
16
- category TEXT,
17
- created_at TEXT DEFAULT (datetime('now'))
18
- );
19
- CREATE TABLE IF NOT EXISTS rss_articles (
20
- id INTEGER PRIMARY KEY AUTOINCREMENT,
21
- source_url TEXT NOT NULL,
22
- title TEXT,
23
- link TEXT NOT NULL,
24
- published TEXT,
25
- summary TEXT,
26
- fetched_at TEXT DEFAULT (datetime('now')),
27
- is_new INTEGER DEFAULT 1,
28
- UNIQUE(source_url, link)
29
- );
11
+ db.exec(`
12
+ CREATE TABLE IF NOT EXISTS rss_sources (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ url TEXT NOT NULL UNIQUE,
15
+ name TEXT,
16
+ category TEXT,
17
+ created_at TEXT DEFAULT (datetime('now'))
18
+ );
19
+ CREATE TABLE IF NOT EXISTS rss_articles (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ source_url TEXT NOT NULL,
22
+ title TEXT,
23
+ link TEXT NOT NULL,
24
+ published TEXT,
25
+ summary TEXT,
26
+ fetched_at TEXT DEFAULT (datetime('now')),
27
+ is_new INTEGER DEFAULT 1,
28
+ UNIQUE(source_url, link)
29
+ );
30
30
  `);
31
31
  }
32
32
  /** Extract text content from an XML tag, handling CDATA */
@@ -266,14 +266,14 @@ export const rssTools = [
266
266
  const format = args.format || "markdown";
267
267
  // Query new articles
268
268
  const params = [sinceHours];
269
- let query = `
270
- SELECT a.title, a.link, a.published, a.summary, a.source_url, a.fetched_at,
271
- COALESCE(s.name, a.source_url) as source_name,
272
- COALESCE(s.category, 'uncategorized') as category
273
- FROM rss_articles a
274
- LEFT JOIN rss_sources s ON a.source_url = s.url
275
- WHERE a.is_new = 1
276
- AND a.fetched_at >= datetime('now', '-' || ? || ' hours')
269
+ let query = `
270
+ SELECT a.title, a.link, a.published, a.summary, a.source_url, a.fetched_at,
271
+ COALESCE(s.name, a.source_url) as source_name,
272
+ COALESCE(s.category, 'uncategorized') as category
273
+ FROM rss_articles a
274
+ LEFT JOIN rss_sources s ON a.source_url = s.url
275
+ WHERE a.is_new = 1
276
+ AND a.fetched_at >= datetime('now', '-' || ? || ' hours')
277
277
  `;
278
278
  if (category) {
279
279
  query += " AND s.category = ?";
@@ -283,9 +283,9 @@ export const rssTools = [
283
283
  const articles = db.prepare(query).all(...params);
284
284
  // Mark articles as seen
285
285
  const markParams = [sinceHours];
286
- let markQuery = `
287
- UPDATE rss_articles SET is_new = 0
288
- WHERE is_new = 1 AND fetched_at >= datetime('now', '-' || ? || ' hours')
286
+ let markQuery = `
287
+ UPDATE rss_articles SET is_new = 0
288
+ WHERE is_new = 1 AND fetched_at >= datetime('now', '-' || ? || ' hours')
289
289
  `;
290
290
  if (category) {
291
291
  markQuery += ` AND source_url IN (SELECT url FROM rss_sources WHERE category = ?)`;
@@ -326,24 +326,24 @@ export const rssTools = [
326
326
  }
327
327
  if (format === "html") {
328
328
  const sections = [...byCategory.entries()]
329
- .map(([cat, items]) => `
330
- <h2 style="color:#333;border-bottom:2px solid #007bff;padding-bottom:4px">${cat} (${items.length})</h2>
329
+ .map(([cat, items]) => `
330
+ <h2 style="color:#333;border-bottom:2px solid #007bff;padding-bottom:4px">${cat} (${items.length})</h2>
331
331
  ${items
332
- .map((a) => `
333
- <div style="margin-bottom:16px;padding:12px;background:#f8f9fa;border-radius:6px">
334
- <a href="${a.link}" style="font-size:16px;font-weight:600;color:#007bff;text-decoration:none">${a.title}</a>
335
- <div style="color:#666;font-size:12px;margin-top:4px">${a.source_name} · ${a.published || a.fetched_at}</div>
336
- ${a.summary ? `<p style="margin-top:8px;color:#444;font-size:14px">${a.summary}</p>` : ""}
332
+ .map((a) => `
333
+ <div style="margin-bottom:16px;padding:12px;background:#f8f9fa;border-radius:6px">
334
+ <a href="${a.link}" style="font-size:16px;font-weight:600;color:#007bff;text-decoration:none">${a.title}</a>
335
+ <div style="color:#666;font-size:12px;margin-top:4px">${a.source_name} · ${a.published || a.fetched_at}</div>
336
+ ${a.summary ? `<p style="margin-top:8px;color:#444;font-size:14px">${a.summary}</p>` : ""}
337
337
  </div>`)
338
338
  .join("")}`)
339
339
  .join("");
340
- const html = `
341
- <div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:640px;margin:0 auto;padding:20px">
342
- <h1 style="color:#111;margin-bottom:4px">Research Digest</h1>
343
- <p style="color:#666;margin-top:0">${articles.length} new articles · ${new Date().toLocaleDateString()}</p>
344
- ${sections}
345
- <hr style="margin-top:32px;border:none;border-top:1px solid #ddd">
346
- <p style="color:#999;font-size:11px">Generated by NodeBench MCP · build_research_digest</p>
340
+ const html = `
341
+ <div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:640px;margin:0 auto;padding:20px">
342
+ <h1 style="color:#111;margin-bottom:4px">Research Digest</h1>
343
+ <p style="color:#666;margin-top:0">${articles.length} new articles · ${new Date().toLocaleDateString()}</p>
344
+ ${sections}
345
+ <hr style="margin-top:32px;border:none;border-top:1px solid #ddd">
346
+ <p style="color:#999;font-size:11px">Generated by NodeBench MCP · build_research_digest</p>
347
347
  </div>`;
348
348
  return [
349
349
  {
@@ -427,299 +427,299 @@ export const rssTools = [
427
427
  ? feeds.map((f) => ` { url: "${f.url}", name: "${f.name || f.url}", category: "${f.category || "general"}" },`).join("\n")
428
428
  : ` // Add your feeds here:\n // { url: "https://arxiv.org/rss/cs.AI", name: "arXiv AI", category: "ai-research" },\n // { url: "https://hnrss.org/newest?points=100", name: "Hacker News Top", category: "tech" },`;
429
429
  // ── Generate the main script ──
430
- const mainScript = `#!/usr/bin/env node
431
- /**
432
- * ${projectName} — Automated Research Digest Pipeline
433
- *
434
- * Subscribe to RSS/Atom feeds, fetch new articles, build a digest, and email it.
435
- * Generated by NodeBench MCP scaffold_research_pipeline.
436
- *
437
- * Usage:
438
- * node digest.mjs # Run once (fetch + digest + email)
439
- * node digest.mjs --fetch-only # Just fetch, don't email
440
- * node digest.mjs --list-sources # Show registered feeds
441
- * node digest.mjs --add-feed <url> # Add a new feed
442
- *
443
- * Schedule with cron:
444
- * crontab -e
445
- * ${cronExpr[schedule]} cd /path/to/${projectName} && node digest.mjs >> digest.log 2>&1
446
- */
447
-
448
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
449
- import * as tls from "node:tls";
450
-
451
- // ── Config ────────────────────────────────────────────────────────────────────
452
-
453
- const DATA_DIR = new URL("./data/", import.meta.url).pathname.replace(/^\\//, "");
454
- if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
455
-
456
- const SOURCES_FILE = DATA_DIR + "sources.json";
457
- const ARTICLES_FILE = DATA_DIR + "articles.json";
458
-
459
- const EMAIL_USER = process.env.EMAIL_USER || "";
460
- const EMAIL_PASS = process.env.EMAIL_PASS || "";
461
- const EMAIL_TO = process.env.DIGEST_TO || "${emailTo}" || EMAIL_USER;
462
- const SMTP_HOST = process.env.EMAIL_SMTP_HOST || "smtp.gmail.com";
463
- const SMTP_PORT = parseInt(process.env.EMAIL_SMTP_PORT || "465");
464
-
465
- // ── Feed storage (JSON file-based, no SQLite needed) ──────────────────────────
466
-
467
- function loadSources() {
468
- if (!existsSync(SOURCES_FILE)) return [];
469
- return JSON.parse(readFileSync(SOURCES_FILE, "utf-8"));
470
- }
471
-
472
- function saveSources(sources) {
473
- writeFileSync(SOURCES_FILE, JSON.stringify(sources, null, 2));
474
- }
475
-
476
- function loadArticles() {
477
- if (!existsSync(ARTICLES_FILE)) return {};
478
- return JSON.parse(readFileSync(ARTICLES_FILE, "utf-8"));
479
- }
480
-
481
- function saveArticles(articles) {
482
- writeFileSync(ARTICLES_FILE, JSON.stringify(articles, null, 2));
483
- }
484
-
485
- // ── RSS/Atom parser ───────────────────────────────────────────────────────────
486
-
487
- function extractTag(xml, tag) {
488
- const m = xml.match(new RegExp(\`<\${tag}[^>]*>\\\\s*(?:<!\\\\[CDATA\\\\[)?([\\\\s\\\\S]*?)(?:\\\\]\\\\]>)?\\\\s*</\${tag}>\`, "i"));
489
- return m ? m[1].trim() : "";
490
- }
491
-
492
- function extractAtomLink(xml) {
493
- const alt = xml.match(/<link[^>]*rel="alternate"[^>]*href="([^"]+)"/i);
494
- if (alt) return alt[1];
495
- const any = xml.match(/<link[^>]*href="([^"]+)"/i);
496
- return any ? any[1] : "";
497
- }
498
-
499
- function stripHtml(html) {
500
- return html.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<")
501
- .replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'")
502
- .replace(/\\s+/g, " ").trim();
503
- }
504
-
505
- async function parseFeed(url) {
506
- const res = await fetch(url, {
507
- headers: { "User-Agent": "${projectName} RSS Reader" },
508
- signal: AbortSignal.timeout(15000),
509
- });
510
- if (!res.ok) throw new Error(\`Feed fetch failed: \${res.status}\`);
511
- const xml = await res.text();
512
- const items = [];
513
-
514
- const rssItems = xml.match(/<item>([\\s\\S]*?)<\\/item>/gi) || [];
515
- if (rssItems.length > 0) {
516
- for (const item of rssItems) {
517
- items.push({
518
- title: stripHtml(extractTag(item, "title")),
519
- link: extractTag(item, "link"),
520
- published: extractTag(item, "pubDate"),
521
- summary: stripHtml(extractTag(item, "description")).substring(0, 500),
522
- });
523
- }
524
- return { title: stripHtml(extractTag(xml, "title")), items };
525
- }
526
-
527
- const atomEntries = xml.match(/<entry>([\\s\\S]*?)<\\/entry>/gi) || [];
528
- for (const entry of atomEntries) {
529
- items.push({
530
- title: stripHtml(extractTag(entry, "title")),
531
- link: extractAtomLink(entry) || extractTag(entry, "link"),
532
- published: extractTag(entry, "published") || extractTag(entry, "updated"),
533
- summary: stripHtml(extractTag(entry, "summary") || extractTag(entry, "content")).substring(0, 500),
534
- });
535
- }
536
- return { title: stripHtml(extractTag(xml, "title")), items };
537
- }
538
-
539
- // ── SMTP email sender ─────────────────────────────────────────────────────────
540
-
541
- function readSmtp(socket, timeout = 10000) {
542
- return new Promise((resolve, reject) => {
543
- let buf = "";
544
- const timer = setTimeout(() => { socket.removeAllListeners("data"); reject(new Error("SMTP timeout")); }, timeout);
545
- const onData = (chunk) => {
546
- buf += chunk.toString();
547
- const lines = buf.split("\\r\\n").filter(Boolean);
548
- const last = lines[lines.length - 1];
549
- if (last && /^\\d{3} /.test(last)) {
550
- clearTimeout(timer); socket.removeListener("data", onData);
551
- const code = parseInt(last.substring(0, 3));
552
- if (code >= 400) reject(new Error(\`SMTP \${code}: \${buf.trim()}\`));
553
- else resolve(buf.trim());
554
- }
555
- };
556
- socket.on("data", onData);
557
- });
558
- }
559
-
560
- async function smtpCmd(socket, cmd) { socket.write(cmd + "\\r\\n"); return readSmtp(socket); }
561
-
562
- async function sendDigestEmail(to, subject, html, plainText) {
563
- if (!EMAIL_USER || !EMAIL_PASS) {
564
- console.log(" [skip] EMAIL_USER/EMAIL_PASS not set — digest printed to stdout instead");
565
- console.log(plainText);
566
- return;
567
- }
568
-
569
- const boundary = "----DigestBoundary" + Date.now();
570
- const message = [
571
- \`From: \${EMAIL_USER}\`, \`To: \${to}\`, \`Subject: \${subject}\`,
572
- \`Date: \${new Date().toUTCString()}\`, "MIME-Version: 1.0",
573
- \`Content-Type: multipart/alternative; boundary="\${boundary}"\`,
574
- "", \`--\${boundary}\`, "Content-Type: text/plain; charset=UTF-8", "", plainText,
575
- \`--\${boundary}\`, "Content-Type: text/html; charset=UTF-8", "", html,
576
- \`--\${boundary}--\`,
577
- ].join("\\r\\n");
578
-
579
- const socket = tls.connect({ host: SMTP_HOST, port: SMTP_PORT, rejectUnauthorized: true });
580
- await new Promise((resolve, reject) => { socket.once("secureConnect", resolve); socket.once("error", reject); });
581
- try {
582
- await readSmtp(socket);
583
- await smtpCmd(socket, "EHLO digest-pipeline");
584
- await smtpCmd(socket, "AUTH LOGIN");
585
- await smtpCmd(socket, Buffer.from(EMAIL_USER).toString("base64"));
586
- await smtpCmd(socket, Buffer.from(EMAIL_PASS).toString("base64"));
587
- await smtpCmd(socket, \`MAIL FROM:<\${EMAIL_USER}>\`);
588
- await smtpCmd(socket, \`RCPT TO:<\${to}>\`);
589
- await smtpCmd(socket, "DATA");
590
- socket.write(message.replace(/\\r\\n\\./g, "\\r\\n..") + "\\r\\n.\\r\\n");
591
- await readSmtp(socket);
592
- await smtpCmd(socket, "QUIT").catch(() => {});
593
- console.log(\` [sent] Digest emailed to \${to}\`);
594
- } finally { socket.destroy(); }
595
- }
596
-
597
- // ── Main pipeline ─────────────────────────────────────────────────────────────
598
-
599
- async function fetchAll() {
600
- const sources = loadSources();
601
- if (sources.length === 0) {
602
- console.log("No sources registered. Use: node digest.mjs --add-feed <url> [name] [category]");
603
- return [];
604
- }
605
-
606
- const seenArticles = loadArticles();
607
- const newArticles = [];
608
-
609
- for (const source of sources) {
610
- try {
611
- const feed = await parseFeed(source.url);
612
- let newCount = 0;
613
- for (const item of feed.items.slice(0, 20)) {
614
- if (!item.link) continue;
615
- const key = source.url + "|" + item.link;
616
- if (seenArticles[key]) continue;
617
- seenArticles[key] = { fetchedAt: new Date().toISOString(), seen: false };
618
- newArticles.push({ ...item, sourceName: source.name, category: source.category });
619
- newCount++;
620
- }
621
- console.log(\` [\${source.name}] \${feed.items.length} articles, \${newCount} new\`);
622
- } catch (e) {
623
- console.log(\` [\${source.name}] ERROR: \${e.message}\`);
624
- }
625
- }
626
-
627
- saveArticles(seenArticles);
628
- return newArticles;
629
- }
630
-
631
- function buildDigest(articles) {
632
- if (articles.length === 0) return { html: "", plainText: "", count: 0 };
633
-
634
- const byCategory = new Map();
635
- for (const a of articles) {
636
- const cat = a.category || "general";
637
- if (!byCategory.has(cat)) byCategory.set(cat, []);
638
- byCategory.get(cat).push(a);
639
- }
640
-
641
- // Plain text
642
- const lines = [\`Research Digest — \${articles.length} new articles — \${new Date().toLocaleDateString()}\`, ""];
643
- for (const [cat, items] of byCategory) {
644
- lines.push(\`## \${cat} (\${items.length})\`, "");
645
- for (const a of items) {
646
- lines.push(\`- \${a.title}\`);
647
- lines.push(\` \${a.sourceName} · \${a.published || "recent"}\`);
648
- lines.push(\` \${a.link}\`);
649
- if (a.summary) lines.push(\` > \${a.summary.substring(0, 200)}\`);
650
- lines.push("");
651
- }
652
- }
653
-
654
- // HTML
655
- const sections = [...byCategory.entries()].map(([cat, items]) => \`
656
- <h2 style="color:#333;border-bottom:2px solid #007bff;padding-bottom:4px">\${cat} (\${items.length})</h2>
657
- \${items.map(a => \`
658
- <div style="margin-bottom:16px;padding:12px;background:#f8f9fa;border-radius:6px">
659
- <a href="\${a.link}" style="font-size:16px;font-weight:600;color:#007bff;text-decoration:none">\${a.title}</a>
660
- <div style="color:#666;font-size:12px;margin-top:4px">\${a.sourceName} · \${a.published || "recent"}</div>
661
- \${a.summary ? \`<p style="margin-top:8px;color:#444;font-size:14px">\${a.summary.substring(0, 300)}</p>\` : ""}
662
- </div>\`).join("")}\`).join("");
663
-
664
- const html = \`<div style="font-family:-apple-system,sans-serif;max-width:640px;margin:0 auto;padding:20px">
665
- <h1 style="color:#111">Research Digest</h1>
666
- <p style="color:#666">\${articles.length} new articles · \${new Date().toLocaleDateString()}</p>
667
- \${sections}
668
- <hr style="margin-top:32px;border:none;border-top:1px solid #ddd">
669
- <p style="color:#999;font-size:11px">Generated by ${projectName}</p>
670
- </div>\`;
671
-
672
- return { html, plainText: lines.join("\\n"), count: articles.length };
673
- }
674
-
675
- // ── CLI ───────────────────────────────────────────────────────────────────────
676
-
677
- const args = process.argv.slice(2);
678
-
679
- if (args.includes("--list-sources")) {
680
- const sources = loadSources();
681
- if (sources.length === 0) console.log("No sources. Use --add-feed <url> [name] [category]");
682
- else sources.forEach((s, i) => console.log(\`\${i + 1}. [\${s.category}] \${s.name} — \${s.url}\`));
683
- } else if (args.includes("--add-feed")) {
684
- const idx = args.indexOf("--add-feed");
685
- const url = args[idx + 1];
686
- const name = args[idx + 2] || url;
687
- const category = args[idx + 3] || "general";
688
- if (!url) { console.error("Usage: --add-feed <url> [name] [category]"); process.exit(1); }
689
- try {
690
- const feed = await parseFeed(url);
691
- const sources = loadSources();
692
- sources.push({ url, name, category });
693
- saveSources(sources);
694
- console.log(\`Added: \${name} (\${feed.title}) — \${feed.items.length} articles available\`);
695
- } catch (e) {
696
- console.error(\`Failed to validate feed: \${e.message}\`);
697
- }
698
- } else {
699
- console.log(\`\\n${"=".repeat(60)}\`);
700
- console.log(\` ${projectName} — \${new Date().toISOString()}\`);
701
- console.log(\`${"=".repeat(60)}\\n\`);
702
-
703
- console.log("Fetching feeds...");
704
- const articles = await fetchAll();
705
-
706
- if (articles.length === 0) {
707
- console.log("\\nNo new articles found.");
708
- } else {
709
- const digest = buildDigest(articles);
710
- console.log(\`\\nDigest: \${digest.count} new articles\`);
711
-
712
- if (!args.includes("--fetch-only")) {
713
- const subject = \`Research Digest — \${digest.count} articles — \${new Date().toLocaleDateString()}\`;
714
- await sendDigestEmail(EMAIL_TO, subject, digest.html, digest.plainText);
715
- }
716
-
717
- // Save digest to file
718
- const digestFile = DATA_DIR + \`digest-\${new Date().toISOString().slice(0,10)}.md\`;
719
- writeFileSync(digestFile, digest.plainText);
720
- console.log(\` [saved] \${digestFile}\`);
721
- }
722
- }
430
+ const mainScript = `#!/usr/bin/env node
431
+ /**
432
+ * ${projectName} — Automated Research Digest Pipeline
433
+ *
434
+ * Subscribe to RSS/Atom feeds, fetch new articles, build a digest, and email it.
435
+ * Generated by NodeBench MCP scaffold_research_pipeline.
436
+ *
437
+ * Usage:
438
+ * node digest.mjs # Run once (fetch + digest + email)
439
+ * node digest.mjs --fetch-only # Just fetch, don't email
440
+ * node digest.mjs --list-sources # Show registered feeds
441
+ * node digest.mjs --add-feed <url> # Add a new feed
442
+ *
443
+ * Schedule with cron:
444
+ * crontab -e
445
+ * ${cronExpr[schedule]} cd /path/to/${projectName} && node digest.mjs >> digest.log 2>&1
446
+ */
447
+
448
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
449
+ import * as tls from "node:tls";
450
+
451
+ // ── Config ────────────────────────────────────────────────────────────────────
452
+
453
+ const DATA_DIR = new URL("./data/", import.meta.url).pathname.replace(/^\\//, "");
454
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
455
+
456
+ const SOURCES_FILE = DATA_DIR + "sources.json";
457
+ const ARTICLES_FILE = DATA_DIR + "articles.json";
458
+
459
+ const EMAIL_USER = process.env.EMAIL_USER || "";
460
+ const EMAIL_PASS = process.env.EMAIL_PASS || "";
461
+ const EMAIL_TO = process.env.DIGEST_TO || "${emailTo}" || EMAIL_USER;
462
+ const SMTP_HOST = process.env.EMAIL_SMTP_HOST || "smtp.gmail.com";
463
+ const SMTP_PORT = parseInt(process.env.EMAIL_SMTP_PORT || "465");
464
+
465
+ // ── Feed storage (JSON file-based, no SQLite needed) ──────────────────────────
466
+
467
+ function loadSources() {
468
+ if (!existsSync(SOURCES_FILE)) return [];
469
+ return JSON.parse(readFileSync(SOURCES_FILE, "utf-8"));
470
+ }
471
+
472
+ function saveSources(sources) {
473
+ writeFileSync(SOURCES_FILE, JSON.stringify(sources, null, 2));
474
+ }
475
+
476
+ function loadArticles() {
477
+ if (!existsSync(ARTICLES_FILE)) return {};
478
+ return JSON.parse(readFileSync(ARTICLES_FILE, "utf-8"));
479
+ }
480
+
481
+ function saveArticles(articles) {
482
+ writeFileSync(ARTICLES_FILE, JSON.stringify(articles, null, 2));
483
+ }
484
+
485
+ // ── RSS/Atom parser ───────────────────────────────────────────────────────────
486
+
487
+ function extractTag(xml, tag) {
488
+ const m = xml.match(new RegExp(\`<\${tag}[^>]*>\\\\s*(?:<!\\\\[CDATA\\\\[)?([\\\\s\\\\S]*?)(?:\\\\]\\\\]>)?\\\\s*</\${tag}>\`, "i"));
489
+ return m ? m[1].trim() : "";
490
+ }
491
+
492
+ function extractAtomLink(xml) {
493
+ const alt = xml.match(/<link[^>]*rel="alternate"[^>]*href="([^"]+)"/i);
494
+ if (alt) return alt[1];
495
+ const any = xml.match(/<link[^>]*href="([^"]+)"/i);
496
+ return any ? any[1] : "";
497
+ }
498
+
499
+ function stripHtml(html) {
500
+ return html.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<")
501
+ .replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'")
502
+ .replace(/\\s+/g, " ").trim();
503
+ }
504
+
505
+ async function parseFeed(url) {
506
+ const res = await fetch(url, {
507
+ headers: { "User-Agent": "${projectName} RSS Reader" },
508
+ signal: AbortSignal.timeout(15000),
509
+ });
510
+ if (!res.ok) throw new Error(\`Feed fetch failed: \${res.status}\`);
511
+ const xml = await res.text();
512
+ const items = [];
513
+
514
+ const rssItems = xml.match(/<item>([\\s\\S]*?)<\\/item>/gi) || [];
515
+ if (rssItems.length > 0) {
516
+ for (const item of rssItems) {
517
+ items.push({
518
+ title: stripHtml(extractTag(item, "title")),
519
+ link: extractTag(item, "link"),
520
+ published: extractTag(item, "pubDate"),
521
+ summary: stripHtml(extractTag(item, "description")).substring(0, 500),
522
+ });
523
+ }
524
+ return { title: stripHtml(extractTag(xml, "title")), items };
525
+ }
526
+
527
+ const atomEntries = xml.match(/<entry>([\\s\\S]*?)<\\/entry>/gi) || [];
528
+ for (const entry of atomEntries) {
529
+ items.push({
530
+ title: stripHtml(extractTag(entry, "title")),
531
+ link: extractAtomLink(entry) || extractTag(entry, "link"),
532
+ published: extractTag(entry, "published") || extractTag(entry, "updated"),
533
+ summary: stripHtml(extractTag(entry, "summary") || extractTag(entry, "content")).substring(0, 500),
534
+ });
535
+ }
536
+ return { title: stripHtml(extractTag(xml, "title")), items };
537
+ }
538
+
539
+ // ── SMTP email sender ─────────────────────────────────────────────────────────
540
+
541
+ function readSmtp(socket, timeout = 10000) {
542
+ return new Promise((resolve, reject) => {
543
+ let buf = "";
544
+ const timer = setTimeout(() => { socket.removeAllListeners("data"); reject(new Error("SMTP timeout")); }, timeout);
545
+ const onData = (chunk) => {
546
+ buf += chunk.toString();
547
+ const lines = buf.split("\\r\\n").filter(Boolean);
548
+ const last = lines[lines.length - 1];
549
+ if (last && /^\\d{3} /.test(last)) {
550
+ clearTimeout(timer); socket.removeListener("data", onData);
551
+ const code = parseInt(last.substring(0, 3));
552
+ if (code >= 400) reject(new Error(\`SMTP \${code}: \${buf.trim()}\`));
553
+ else resolve(buf.trim());
554
+ }
555
+ };
556
+ socket.on("data", onData);
557
+ });
558
+ }
559
+
560
+ async function smtpCmd(socket, cmd) { socket.write(cmd + "\\r\\n"); return readSmtp(socket); }
561
+
562
+ async function sendDigestEmail(to, subject, html, plainText) {
563
+ if (!EMAIL_USER || !EMAIL_PASS) {
564
+ console.log(" [skip] EMAIL_USER/EMAIL_PASS not set — digest printed to stdout instead");
565
+ console.log(plainText);
566
+ return;
567
+ }
568
+
569
+ const boundary = "----DigestBoundary" + Date.now();
570
+ const message = [
571
+ \`From: \${EMAIL_USER}\`, \`To: \${to}\`, \`Subject: \${subject}\`,
572
+ \`Date: \${new Date().toUTCString()}\`, "MIME-Version: 1.0",
573
+ \`Content-Type: multipart/alternative; boundary="\${boundary}"\`,
574
+ "", \`--\${boundary}\`, "Content-Type: text/plain; charset=UTF-8", "", plainText,
575
+ \`--\${boundary}\`, "Content-Type: text/html; charset=UTF-8", "", html,
576
+ \`--\${boundary}--\`,
577
+ ].join("\\r\\n");
578
+
579
+ const socket = tls.connect({ host: SMTP_HOST, port: SMTP_PORT, rejectUnauthorized: true });
580
+ await new Promise((resolve, reject) => { socket.once("secureConnect", resolve); socket.once("error", reject); });
581
+ try {
582
+ await readSmtp(socket);
583
+ await smtpCmd(socket, "EHLO digest-pipeline");
584
+ await smtpCmd(socket, "AUTH LOGIN");
585
+ await smtpCmd(socket, Buffer.from(EMAIL_USER).toString("base64"));
586
+ await smtpCmd(socket, Buffer.from(EMAIL_PASS).toString("base64"));
587
+ await smtpCmd(socket, \`MAIL FROM:<\${EMAIL_USER}>\`);
588
+ await smtpCmd(socket, \`RCPT TO:<\${to}>\`);
589
+ await smtpCmd(socket, "DATA");
590
+ socket.write(message.replace(/\\r\\n\\./g, "\\r\\n..") + "\\r\\n.\\r\\n");
591
+ await readSmtp(socket);
592
+ await smtpCmd(socket, "QUIT").catch(() => {});
593
+ console.log(\` [sent] Digest emailed to \${to}\`);
594
+ } finally { socket.destroy(); }
595
+ }
596
+
597
+ // ── Main pipeline ─────────────────────────────────────────────────────────────
598
+
599
+ async function fetchAll() {
600
+ const sources = loadSources();
601
+ if (sources.length === 0) {
602
+ console.log("No sources registered. Use: node digest.mjs --add-feed <url> [name] [category]");
603
+ return [];
604
+ }
605
+
606
+ const seenArticles = loadArticles();
607
+ const newArticles = [];
608
+
609
+ for (const source of sources) {
610
+ try {
611
+ const feed = await parseFeed(source.url);
612
+ let newCount = 0;
613
+ for (const item of feed.items.slice(0, 20)) {
614
+ if (!item.link) continue;
615
+ const key = source.url + "|" + item.link;
616
+ if (seenArticles[key]) continue;
617
+ seenArticles[key] = { fetchedAt: new Date().toISOString(), seen: false };
618
+ newArticles.push({ ...item, sourceName: source.name, category: source.category });
619
+ newCount++;
620
+ }
621
+ console.log(\` [\${source.name}] \${feed.items.length} articles, \${newCount} new\`);
622
+ } catch (e) {
623
+ console.log(\` [\${source.name}] ERROR: \${e.message}\`);
624
+ }
625
+ }
626
+
627
+ saveArticles(seenArticles);
628
+ return newArticles;
629
+ }
630
+
631
+ function buildDigest(articles) {
632
+ if (articles.length === 0) return { html: "", plainText: "", count: 0 };
633
+
634
+ const byCategory = new Map();
635
+ for (const a of articles) {
636
+ const cat = a.category || "general";
637
+ if (!byCategory.has(cat)) byCategory.set(cat, []);
638
+ byCategory.get(cat).push(a);
639
+ }
640
+
641
+ // Plain text
642
+ const lines = [\`Research Digest — \${articles.length} new articles — \${new Date().toLocaleDateString()}\`, ""];
643
+ for (const [cat, items] of byCategory) {
644
+ lines.push(\`## \${cat} (\${items.length})\`, "");
645
+ for (const a of items) {
646
+ lines.push(\`- \${a.title}\`);
647
+ lines.push(\` \${a.sourceName} · \${a.published || "recent"}\`);
648
+ lines.push(\` \${a.link}\`);
649
+ if (a.summary) lines.push(\` > \${a.summary.substring(0, 200)}\`);
650
+ lines.push("");
651
+ }
652
+ }
653
+
654
+ // HTML
655
+ const sections = [...byCategory.entries()].map(([cat, items]) => \`
656
+ <h2 style="color:#333;border-bottom:2px solid #007bff;padding-bottom:4px">\${cat} (\${items.length})</h2>
657
+ \${items.map(a => \`
658
+ <div style="margin-bottom:16px;padding:12px;background:#f8f9fa;border-radius:6px">
659
+ <a href="\${a.link}" style="font-size:16px;font-weight:600;color:#007bff;text-decoration:none">\${a.title}</a>
660
+ <div style="color:#666;font-size:12px;margin-top:4px">\${a.sourceName} · \${a.published || "recent"}</div>
661
+ \${a.summary ? \`<p style="margin-top:8px;color:#444;font-size:14px">\${a.summary.substring(0, 300)}</p>\` : ""}
662
+ </div>\`).join("")}\`).join("");
663
+
664
+ const html = \`<div style="font-family:-apple-system,sans-serif;max-width:640px;margin:0 auto;padding:20px">
665
+ <h1 style="color:#111">Research Digest</h1>
666
+ <p style="color:#666">\${articles.length} new articles · \${new Date().toLocaleDateString()}</p>
667
+ \${sections}
668
+ <hr style="margin-top:32px;border:none;border-top:1px solid #ddd">
669
+ <p style="color:#999;font-size:11px">Generated by ${projectName}</p>
670
+ </div>\`;
671
+
672
+ return { html, plainText: lines.join("\\n"), count: articles.length };
673
+ }
674
+
675
+ // ── CLI ───────────────────────────────────────────────────────────────────────
676
+
677
+ const args = process.argv.slice(2);
678
+
679
+ if (args.includes("--list-sources")) {
680
+ const sources = loadSources();
681
+ if (sources.length === 0) console.log("No sources. Use --add-feed <url> [name] [category]");
682
+ else sources.forEach((s, i) => console.log(\`\${i + 1}. [\${s.category}] \${s.name} — \${s.url}\`));
683
+ } else if (args.includes("--add-feed")) {
684
+ const idx = args.indexOf("--add-feed");
685
+ const url = args[idx + 1];
686
+ const name = args[idx + 2] || url;
687
+ const category = args[idx + 3] || "general";
688
+ if (!url) { console.error("Usage: --add-feed <url> [name] [category]"); process.exit(1); }
689
+ try {
690
+ const feed = await parseFeed(url);
691
+ const sources = loadSources();
692
+ sources.push({ url, name, category });
693
+ saveSources(sources);
694
+ console.log(\`Added: \${name} (\${feed.title}) — \${feed.items.length} articles available\`);
695
+ } catch (e) {
696
+ console.error(\`Failed to validate feed: \${e.message}\`);
697
+ }
698
+ } else {
699
+ console.log(\`\\n${"=".repeat(60)}\`);
700
+ console.log(\` ${projectName} — \${new Date().toISOString()}\`);
701
+ console.log(\`${"=".repeat(60)}\\n\`);
702
+
703
+ console.log("Fetching feeds...");
704
+ const articles = await fetchAll();
705
+
706
+ if (articles.length === 0) {
707
+ console.log("\\nNo new articles found.");
708
+ } else {
709
+ const digest = buildDigest(articles);
710
+ console.log(\`\\nDigest: \${digest.count} new articles\`);
711
+
712
+ if (!args.includes("--fetch-only")) {
713
+ const subject = \`Research Digest — \${digest.count} articles — \${new Date().toLocaleDateString()}\`;
714
+ await sendDigestEmail(EMAIL_TO, subject, digest.html, digest.plainText);
715
+ }
716
+
717
+ // Save digest to file
718
+ const digestFile = DATA_DIR + \`digest-\${new Date().toISOString().slice(0,10)}.md\`;
719
+ writeFileSync(digestFile, digest.plainText);
720
+ console.log(\` [saved] \${digestFile}\`);
721
+ }
722
+ }
723
723
  `;
724
724
  // ── Generate package.json ──
725
725
  const packageJson = JSON.stringify({
@@ -737,67 +737,67 @@ if (args.includes("--list-sources")) {
737
737
  license: "MIT",
738
738
  }, null, 2);
739
739
  // ── Generate .env template ──
740
- const envTemplate = `# Email configuration (required for email delivery)
741
- # For Gmail: use an App Password (Google Account → Security → App passwords)
742
- EMAIL_USER=your.email@gmail.com
743
- EMAIL_PASS=your-16-char-app-password
744
- DIGEST_TO=${emailTo || "your.email@gmail.com"}
745
-
746
- # Optional: non-Gmail SMTP
747
- # EMAIL_SMTP_HOST=smtp.gmail.com
748
- # EMAIL_SMTP_PORT=465
740
+ const envTemplate = `# Email configuration (required for email delivery)
741
+ # For Gmail: use an App Password (Google Account → Security → App passwords)
742
+ EMAIL_USER=your.email@gmail.com
743
+ EMAIL_PASS=your-16-char-app-password
744
+ DIGEST_TO=${emailTo || "your.email@gmail.com"}
745
+
746
+ # Optional: non-Gmail SMTP
747
+ # EMAIL_SMTP_HOST=smtp.gmail.com
748
+ # EMAIL_SMTP_PORT=465
749
749
  `;
750
750
  // ── Generate README ──
751
- const readme = `# ${projectName}
752
-
753
- Automated research digest pipeline. Subscribes to RSS/Atom feeds, fetches new articles, builds a categorized digest, and emails it to you.
754
-
755
- ## Quick Start
756
-
757
- \`\`\`bash
758
- # 1. Set up email (see .env.example)
759
- cp .env.example .env
760
- # Edit .env with your email credentials
761
-
762
- # 2. Add feeds
763
- node digest.mjs --add-feed "https://arxiv.org/rss/cs.AI" "arXiv AI" "ai-research"
764
- node digest.mjs --add-feed "https://hnrss.org/newest?points=100" "HN Top" "tech"
765
- node digest.mjs --add-feed "https://blog.anthropic.com/rss.xml" "Anthropic" "ai-research"
766
-
767
- # 3. Run
768
- node digest.mjs
769
- \`\`\`
770
-
771
- ## Schedule (cron)
772
-
773
- \`\`\`bash
774
- # Edit crontab
775
- crontab -e
776
-
777
- # Add (${schedule}):
778
- ${cronExpr[schedule]} cd /path/to/${projectName} && node digest.mjs >> digest.log 2>&1
779
- \`\`\`
780
-
781
- ## Commands
782
-
783
- | Command | What it does |
784
- |---|---|
785
- | \`node digest.mjs\` | Fetch + digest + email |
786
- | \`node digest.mjs --fetch-only\` | Fetch only (no email) |
787
- | \`node digest.mjs --list-sources\` | Show registered feeds |
788
- | \`node digest.mjs --add-feed <url> [name] [category]\` | Add a new feed |
789
-
790
- ## How It Works
791
-
792
- 1. **Fetch**: Pulls latest articles from all registered RSS/Atom feeds
793
- 2. **Deduplicate**: Skips articles already seen (tracked in \`data/articles.json\`)
794
- 3. **Digest**: Builds a categorized summary (plain text + HTML)
795
- 4. **Email**: Sends via SMTP over TLS (Gmail default, configurable)
796
- 5. **Save**: Archives digest as markdown in \`data/\`
797
-
798
- No dependencies. Pure Node.js (>= 18). Zero npm packages.
799
-
800
- Generated by [NodeBench MCP](https://www.npmjs.com/package/nodebench-mcp) \`scaffold_research_pipeline\`
751
+ const readme = `# ${projectName}
752
+
753
+ Automated research digest pipeline. Subscribes to RSS/Atom feeds, fetches new articles, builds a categorized digest, and emails it to you.
754
+
755
+ ## Quick Start
756
+
757
+ \`\`\`bash
758
+ # 1. Set up email (see .env.example)
759
+ cp .env.example .env
760
+ # Edit .env with your email credentials
761
+
762
+ # 2. Add feeds
763
+ node digest.mjs --add-feed "https://arxiv.org/rss/cs.AI" "arXiv AI" "ai-research"
764
+ node digest.mjs --add-feed "https://hnrss.org/newest?points=100" "HN Top" "tech"
765
+ node digest.mjs --add-feed "https://blog.anthropic.com/rss.xml" "Anthropic" "ai-research"
766
+
767
+ # 3. Run
768
+ node digest.mjs
769
+ \`\`\`
770
+
771
+ ## Schedule (cron)
772
+
773
+ \`\`\`bash
774
+ # Edit crontab
775
+ crontab -e
776
+
777
+ # Add (${schedule}):
778
+ ${cronExpr[schedule]} cd /path/to/${projectName} && node digest.mjs >> digest.log 2>&1
779
+ \`\`\`
780
+
781
+ ## Commands
782
+
783
+ | Command | What it does |
784
+ |---|---|
785
+ | \`node digest.mjs\` | Fetch + digest + email |
786
+ | \`node digest.mjs --fetch-only\` | Fetch only (no email) |
787
+ | \`node digest.mjs --list-sources\` | Show registered feeds |
788
+ | \`node digest.mjs --add-feed <url> [name] [category]\` | Add a new feed |
789
+
790
+ ## How It Works
791
+
792
+ 1. **Fetch**: Pulls latest articles from all registered RSS/Atom feeds
793
+ 2. **Deduplicate**: Skips articles already seen (tracked in \`data/articles.json\`)
794
+ 3. **Digest**: Builds a categorized summary (plain text + HTML)
795
+ 4. **Email**: Sends via SMTP over TLS (Gmail default, configurable)
796
+ 5. **Save**: Archives digest as markdown in \`data/\`
797
+
798
+ No dependencies. Pure Node.js (>= 18). Zero npm packages.
799
+
800
+ Generated by [NodeBench MCP](https://www.npmjs.com/package/nodebench-mcp) \`scaffold_research_pipeline\`
801
801
  `;
802
802
  return [
803
803
  {