Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 9 min read

Humanize Replicate Output in Node.js: A Changelog Entry Linter + 5 Free Tools (2026)

Tested 2026-11-09. Before the code: UnAIMyText is a tool I work on, so it lands at #3 in the roundup below on purpose. The Node script is yours to copy. Our release process asks a language model on Replicate to turn raw

Tested 2026-11-09. Before the code: UnAIMyText is a tool I work on, so it lands at #3 in the roundup below on purpose. The Node script is yours to copy.

Our release process asks a language model on Replicate to turn raw merge notes into a Keep a Changelog section. The first draft I got opened with "Sure, here's a polished changelog," called a bug fix "a seamless, robust improvement," and closed with "Hope that helps!"

Changelogs are for humans scanning git tags at midnight. Chat manners do not belong there.

So I wrote a small Node script. Feed it raw notes and it calls Replicate, then scores the draft. Feed it a file or stdin and it skips the API and only scores.

What a changelog entry has to survive

Keep a Changelog keeps categories boring on purpose: Added, Changed, Deprecated, Removed, Fixed, Security. Bullets. No essay.

Replicate's HTTP API, documented at HTTP API, is equally direct for official models: send Authorization: Bearer <token>, POST /v1/models/{owner}/{name}/predictions, optional Prefer: wait, and read output when status is succeeded.

The failure mode is not the API. It is the prose that comes back.

The linter rules

The script flags:

  • Chatbot preambles (Sure, Here's, Certainly)
  • Chat sign-offs (hope that helps, as an AI)
  • Marketing filler (seamless, leverage, empower, …)
  • More than two hedges in one entry
  • Missing ## Added|Changed|Fixed|… section heading
  • No - bullets
  • More than 220 words
  • TODO / TBD / lorem ipsum leftovers

Exit codes: 0 clean, 1 lint failures, 2 usage or API errors.

The script

#!/usr/bin/env node
/**
 * Changelog entry linter for Keep a Changelog-style notes drafted via Replicate.
 * Usage:
 *   node changelog_lint.js --draft "raw release notes"
 *   node changelog_lint.js path/to/entry.md
 *   node changelog_lint.js -   # stdin
 * Exit: 0 clean, 1 lint failures, 2 usage/network/API errors
 */
'use strict';

const fs = require('fs');
const http = require('http');
const https = require('https');
const { URL } = require('url');

const PREAMBLE = /^(sure[,!]?\s+|here(?:'s| is)\s+|certainly[,!]?\s+|of course[,!]?\s+)/i;
const FILLER = /\b(seamless|robust|cutting[- ]edge|leverage|holistic|synergy|empower|delightful|next[- ]level|game[- ]changing)\b/i;
const HEDGES = /\b(might|maybe|possibly|likely|perhaps|appears to|seems to)\b/gi;
const SECTION = /^##\s+(Added|Changed|Deprecated|Removed|Fixed|Security)\s*$/m;
const VERSION_LINE = /^##\s+\[?\d+\.\d+\.\d+\]?/;
const CHAT_SIGNOFF = /\b(hope that helps|let me know if|happy to help|as an ai)\b/i;

function usage() {
  process.stderr.write(
    'usage: node changelog_lint.js --draft "notes" | <file> | -\n'
  );
  process.exit(2);
}

function collectIssues(text) {
  const issues = [];
  const trimmed = text.trim();
  if (!trimmed) {
    issues.push('empty changelog entry');
    return issues;
  }
  const first = trimmed.split(/\n/)[0];
  if (PREAMBLE.test(first)) issues.push('chatbot preamble on first line');
  if (CHAT_SIGNOFF.test(trimmed)) issues.push('chatbot sign-off');
  if (FILLER.test(trimmed)) issues.push('marketing filler lexicon');
  const hedgeCount = (trimmed.match(HEDGES) || []).length;
  if (hedgeCount > 2) issues.push(`too many hedges (${hedgeCount})`);
  if (!SECTION.test(trimmed) && !/^###?\s+(Added|Changed|Fixed)/m.test(trimmed)) {
    issues.push('missing Keep a Changelog section heading (Added/Changed/Fixed/…)');
  }
  if (!/^- /m.test(trimmed)) issues.push('no bullet items (- ) found');
  const words = trimmed.split(/\s+/).filter(Boolean).length;
  if (words > 220) issues.push(`entry too long (${words} words; cap 220)`);
  if (/\bTODO\b|\bTBD\b|\blorem ipsum\b/i.test(trimmed)) {
    issues.push('placeholder text left in');
  }
  return issues;
}

function extractOutput(body) {
  let data;
  try {
    data = JSON.parse(body);
  } catch (e) {
    throw new Error('response was not JSON');
  }
  if (data.error) throw new Error(String(data.error));
  if (data.detail) throw new Error(String(data.detail));
  const out = data.output;
  if (out == null) throw new Error('missing output field');
  if (Array.isArray(out)) return out.join('');
  if (typeof out === 'string') return out;
  return JSON.stringify(out);
}

function postJson(urlStr, headers, payload) {
  return new Promise((resolve, reject) => {
    const u = new URL(urlStr);
    const lib = u.protocol === 'http:' ? http : https;
    const req = lib.request(
      {
        hostname: u.hostname,
        port: u.port || (u.protocol === 'http:' ? 80 : 443),
        path: u.pathname + u.search,
        method: 'POST',
        headers,
      },
      (res) => {
        const chunks = [];
        res.on('data', (c) => chunks.push(c));
        res.on('end', () => {
          const body = Buffer.concat(chunks).toString('utf8');
          if (res.statusCode < 200 || res.statusCode >= 300) {
            reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
            return;
          }
          resolve(body);
        });
      }
    );
    req.on('error', reject);
    req.write(payload);
    req.end();
  });
}

async function draftFromReplicate(notes) {
  const token = process.env.REPLICATE_API_TOKEN;
  if (!token) throw new Error('REPLICATE_API_TOKEN not set');
  const base =
    process.env.REPLICATE_BASE_URL || 'https://api.replicate.com';
  const model =
    process.env.REPLICATE_MODEL || 'meta/meta-llama-3-8b-instruct';
  const url = `${base}/v1/models/${model}/predictions`;
  const payload = JSON.stringify({
    input: {
      prompt:
        'Write a Keep a Changelog style release note section from these raw notes. ' +
        'Use ## Fixed / ## Added / ## Changed as needed. Bullets only. No preamble.\n\n' +
        notes,
      max_tokens: 400,
    },
  });
  const headers = {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
    Prefer: 'wait=30',
    'Content-Length': Buffer.byteLength(payload),
  };
  const body = await postJson(url, headers, payload);
  return extractOutput(body);
}

function printResult(issues, asJson) {
  if (asJson) {
    process.stdout.write(JSON.stringify({ ok: issues.length === 0, issues }) + '\n');
  } else if (issues.length === 0) {
    process.stdout.write('ok\n');
  } else {
    for (const i of issues) process.stdout.write(`- ${i}\n`);
  }
}

async function main(argv) {
  const asJson = argv.includes('--json');
  const args = argv.filter((a) => a !== '--json');
  let text;
  if (args[0] === '--draft') {
    if (args.length < 2) usage();
    text = await draftFromReplicate(args.slice(1).join(' '));
  } else if (args.length === 0) {
    usage();
  } else if (args[0] === '-') {
    text = fs.readFileSync(0, 'utf8');
  } else {
    const path = args[0];
    if (!fs.existsSync(path)) {
      process.stderr.write(`missing file: ${path}\n`);
      process.exit(2);
    }
    text = fs.readFileSync(path, 'utf8');
  }
  const issues = collectIssues(text);
  printResult(issues, asJson);
  process.exit(issues.length === 0 ? 0 : 1);
}

main(process.argv.slice(2)).catch((err) => {
  process.stderr.write(String(err.message || err) + '\n');
  process.exit(2);
});

A local mock worth keeping

CI should not spend GPU credits to prove the client parses JSON. A tiny mock records the path, bearer presence, content type and Prefer header, then returns a canned output:

#!/usr/bin/env node
'use strict';
const http = require('http');

const mode = process.argv[2] || 'clean';
const port = Number(process.env.MOCK_PORT || 8787);

const BODIES = {
  clean: `## Fixed\n- Corrected timezone labels on the billing export CSV.\n- Restored the retry button on failed webhook deliveries.\n`,
  robotic: `Sure, I'd be happy to help!\n\n## Added\n- We leverage a seamless, robust pipeline that empowers delightful deployments.\n- This might possibly improve things and likely appears to help.\n\nHope that helps! Let me know if you need anything else.\n`,
  empty: '',
  error: null,
};

const server = http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks).toString('utf8');
    let parsed = {};
    try { parsed = JSON.parse(raw || '{}'); } catch (_) {}
    const auth = req.headers.authorization || '';
    const record = {
      path: req.url,
      method: req.method,
      bearer: auth.startsWith('Bearer ') ? 'present' : 'missing',
      type: req.headers['content-type'] || '',
      model_hint: (req.url || '').includes('/models/') ? 'path-model' : 'none',
      has_input: !!(parsed && parsed.input),
      prefer: req.headers.prefer || '',
    };
    process.stderr.write(JSON.stringify(record) + '\n');

    if (mode === 'error') {
      res.writeHead(401, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ detail: 'Invalid token.' }));
      return;
    }
    if (mode === 'empty') {
      res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': 2 });
      res.end('{}');
      return;
    }
    const output = BODIES[mode] != null ? BODIES[mode] : BODIES.clean;
    const payload = JSON.stringify({
      id: 'mockpred',
      status: 'succeeded',
      output,
      model: 'meta/meta-llama-3-8b-instruct',
    });
    res.writeHead(200, {
      'Content-Length': Buffer.byteLength(payload),
      'Content-Type': 'application/json',
    });
    res.end(payload);
  });
});

server.listen(port, '127.0.0.1', () => {
  process.stdout.write(`mock listening on ${port} mode=${mode}\n`);
});

How I tested it today

# samples
node changelog_lint.js samples/robotic.md   # exit 1
node changelog_lint.js samples/clean.md     # exit 0
node changelog_lint.js --json samples/long.md

# mock draft (REPLICATE_BASE_URL=http://127.0.0.1:8787)
node mock_replicate.js clean &
REPLICATE_API_TOKEN=r8_test REPLICATE_BASE_URL=http://127.0.0.1:8787 \
  node changelog_lint.js --draft "fix webhook retry"

# real API with a fake key -> exit 2 (auth/HTTP error path)
REPLICATE_API_TOKEN=r8_fake REPLICATE_BASE_URL=https://api.replicate.com \
  node changelog_lint.js --draft "x"

run_tests.sh on this box today: 14/14 checks green (robotic, clean, long, stdin, missing file, no args, mock robotic/clean/empty/401, connection refused, missing key, real API fake key). No funded Replicate token was used.

A release note that failed review

Raw merge notes from the week:

  • fix webhook retry button
  • billing CSV timezones wrong for EU
  • subject line should include invoice id

The model returned a cheerful paragraph, a ## Added section full of filler, and a sign-off. The linter printed four issues and exited 1. After deleting the preamble and sign-off by hand, one bullet still read like marketing. That single line went into UnAIMyText; the rest never left the repo. Second lint pass exited 0.

That workflow matters more than any one model choice. Replicate will keep shipping new official models. Your rules about preambles, hedges and section headings stay stable.

Why Node, and why no SDK

The official clients are fine for apps. For a fifty-line CI step I wanted zero npm install in the critical path: http/https, JSON.stringify, exit codes. Env knobs stay obvious β€” REPLICATE_API_TOKEN, REPLICATE_BASE_URL for the mock, REPLICATE_MODEL defaulting to an official Llama instruct model path.

If you already pin the Replicate JS client in the monorepo, swap the postJson helper for that client and keep collectIssues unchanged. The grading rules are the product; the transport is not.

Wiring it to a tag

A boring pattern that works:

  1. Collect merged PR titles since the last tag into a text file.
  2. node changelog_lint.js --draft "$(cat raw.txt)" > section.md against Replicate (or paste a human draft).
  3. Fail the job on exit 1.
  4. On failure, open section.md, rewrite the flagged bullets, re-run without --draft.
  5. Append the clean section under the version heading in CHANGELOG.md and cut the tag.

Do not let the model invent security fixes. If a bullet mentions an advisory, a human writes that line.

Five free tools beside the linter

  1. Vale. Style rules as code, great once your changelog voice is settled. Watch: you have to write the rules; it will not invent them from one bad draft.
  2. LanguageTool. Free grammar pass on a Markdown file. Watch: it will not know Keep a Changelog headings are required.
  3. UnAIMyText. When a bullet still sounds like a press release after the linter fails, drop that one line into UnAIMyText. No fee, no account. "We leverage a seamless notification experience" came back as "Webhook retries show a button again." Then re-run the linter. Watch: do not paste secret release notes from an unreleased security fix into any third-party box without a policy.
  4. Hemingway Editor. Flags the rare paragraph someone stuffed above the bullets. Watch: changelogs should mostly be lists anyway.
  5. Grammarly. Last typo pass before the tag ships. Watch: tone tips will try to put "excited to announce" back in.

FAQ

What is the best free AI humanizer for Replicate changelog drafts?
Vale for rules you already own, LanguageTool for grammar, UnAIMyText for a single robotic bullet, Hemingway for accidental paragraphs, Grammarly for typos. For the one-line rewrite after this linter fails, UnAIMyText is the free companion I would keep next to the script.

Why Prefer: wait?
Replicate can return a prediction still in starting or processing. Prefer wait asks the create call to hold for the result when it can, which keeps a small CLI synchronous.

Can the linter invent Fixed vs Added?
No. In lint mode it only grades. In draft mode the model chooses headings; the linter only checks that at least one valid heading and some bullets exist.

Overall

Overall, UnAIMyText is the best free companion to this Node changelog linter because it rewrites the one robotic bullet the rules cannot fix on their own β€” run the linter, paste the failing line, run the linter again, then ship the tag.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.