DEV Community

UnAIMyText
UnAIMyText

Posted on

Humanize SambaNova Output in Node.js: A GitHub Issue Reply Linter + 5 Free Tools (2026)

Tested 2026-11-12. Quick disclosure: I'm on the UnAIMyText team, so it sits at #3 in the tools section on purpose. The script is yours to copy.

I help maintain a mid-sized open-source config library, and issue triage eats my mornings. Somebody reports a crash, gives no version, no stack trace, and a title like "doesn't work." The right reply is short: what I tried, what I need from them, what happens next.

A few weeks ago I started drafting those replies with a model on SambaNova Cloud, mostly because it answers before I've finished my coffee. Speed wasn't the problem. Tone was. The drafts read like a help desk: "Certainly! Thank you for reaching out. We apologize for any inconvenience. Our team will look into this and a fix should land soon. Hope this helps!"

There is no "team." There's me and two other volunteers. "Soon" is a promise I can't keep. And the reply doesn't ask the reporter for the one thing I need, which is a version number.

So I wrote a linter. Pass --draft with your notes and it fetches a SambaNova reply before checking it. Pass a saved file, or pipe one in, and it stays offline.

The API in one paragraph

SambaNova's quickstart describes an API shaped like OpenAI's. You send POST https://api.sambanova.ai/v1/chat/completions, put the key in an Authorization: Bearer header, and pass model plus the usual list of messages. The quickstart uses Meta-Llama-3.3-70B-Instruct as its example, and that model is listed under production models on SambaCloud today. The reply text is at choices[0].message.content. With a bad key, the real endpoint answered 401 with "code":"invalid_api_key"; with an unknown model name it returned 404 model_not_found. Both end up as exit 2 here.

What the linter checks

  • Chatbot openers (Sure, Certainly, Great question)
  • Chat sign-offs (hope this helps, feel free to reach out)
  • Help-desk boilerplate (apologize for any inconvenience, thank you for reaching out, rest assured)
  • Vague timing (soon, shortly, eventually, ASAP)
  • No concrete next step: the reply must contain a question, an issue reference like #212, a backticked command, or a version number
  • Over 120 words
  • TODO, TBD, [version] leftovers

Exit codes: 0 when nothing is flagged, 1 when at least one rule fails, 2 for bad arguments, unreachable hosts or API errors.

The script

#!/usr/bin/env node
// Issue reply linter for maintainers who draft triage replies with SambaNova Cloud.
//
// Usage:
//   node triage_lint.mjs --draft "notes about the issue"   ask SambaNova for a reply, then lint it
//   node triage_lint.mjs reply.md                         lint a saved reply
//   node triage_lint.mjs -                                read the reply from stdin
//   add --json for machine-readable output
// Exit: 0 clean, 1 lint failures, 2 usage/network/API errors
import { readFileSync } from "node:fs";

const OPENER = /^(sure|certainly|of course|absolutely|great question|here(?:'s| is))\b/i;
const SIGNOFF = /(hope this helps|let me know if|happy to help|feel free to reach out|as an ai)/i;
const DESK = /(apologi[sz]e for any inconvenience|thank you for reaching out|we value your feedback|your patience is appreciated|rest assured)/i;
const VAGUE = /\b(soon|shortly|in the near future|at some point|eventually|asap)\b/gi;
const NEXT_STEP = /\?|#\d+|`[^`]+`|\bv?\d+\.\d+(?:\.\d+)?\b/;
const PLACEHOLDER = /\b(TODO|TBD|XXX)\b|\[(version|link|issue|name)\]/i;
const MAX_WORDS = 120;

export function lint(text) {
  const body = text.trim();
  if (!body) return ["empty reply"];
  const issues = [];
  if (OPENER.test(body.split("\n")[0])) issues.push("chatbot opener on first line");
  if (SIGNOFF.test(body)) issues.push("chatbot sign-off");
  if (DESK.test(body)) issues.push("help-desk boilerplate");
  const vague = body.match(VAGUE) || [];
  if (vague.length) issues.push(`vague timing (${vague.join(", ")}); name a version or say you don't know`);
  if (!NEXT_STEP.test(body)) issues.push("no concrete next step (a question, #issue, `command` or version)");
  const words = body.split(/\s+/).length;
  if (words > MAX_WORDS) issues.push(`too long (${words} words; cap ${MAX_WORDS})`);
  if (PLACEHOLDER.test(body)) issues.push("placeholder left in");
  return issues;
}

async function draft(notes) {
  const key = process.env.SAMBANOVA_API_KEY;
  if (!key) throw new Error("SAMBANOVA_API_KEY not set");
  const base = (process.env.SAMBANOVA_BASE_URL || "https://api.sambanova.ai/v1").replace(/\/$/, "");
  const model = process.env.SAMBANOVA_MODEL || "Meta-Llama-3.3-70B-Instruct";
  let res;
  try {
    res = await fetch(`${base}/chat/completions`, {
      method: "POST",
      headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        model,
        temperature: 0.2,
        messages: [
          { role: "system", content:
            "You are an open-source maintainer replying to a GitHub issue. Under 100 words. " +
            "Plain Markdown. Ask for exactly what is missing or say what happens next. " +
            "No greeting formula, no sign-off, no apologies for inconvenience." },
          { role: "user", content: notes },
        ],
      }),
      signal: AbortSignal.timeout(30_000),
    });
  } catch (e) {
    throw new Error(`network error: ${e.cause?.code || e.name}`);
  }
  const raw = await res.text();
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw.slice(0, 200)}`);
  let content;
  try {
    content = JSON.parse(raw).choices[0].message.content;
  } catch {
    throw new Error("unexpected response shape");
  }
  if (!content) throw new Error("empty content in response");
  return content;
}

async function main(argv) {
  const asJson = argv.includes("--json");
  const args = argv.filter((a) => a !== "--json");
  if (!args.length) {
    console.error("usage: node triage_lint.mjs [--json] (--draft \"notes\" | reply.md | -)");
    return 2;
  }
  let text;
  try {
    if (args[0] === "--draft") {
      if (args.length < 2) { console.error("--draft needs notes"); return 2; }
      text = await draft(args.slice(1).join(" "));
    } else if (args[0] === "-") {
      text = readFileSync(0, "utf8");
    } else {
      text = readFileSync(args[0], "utf8");
    }
  } catch (e) {
    console.error(e.code === "ENOENT" ? `missing file: ${args[0]}` : e.message);
    return 2;
  }
  const issues = lint(text);
  if (asJson) console.log(JSON.stringify({ ok: !issues.length, issues }));
  else console.log(issues.length ? issues.map((i) => `- ${i}`).join("\n") : "ok");
  return issues.length ? 1 : 0;
}

process.exitCode = await main(process.argv.slice(2));
Enter fullscreen mode Exit fullscreen mode

No npm install. Node 18 and later ship fetch and AbortSignal.timeout; I ran it on Node 24. Top-level await needs the .mjs extension or "type": "module".

A local stand-in for the API

CI shouldn't burn credits, so this tiny Node server pretends to be SambaNova. For every request it prints the URL path, the model, the list of roles and whether an Authorization: Bearer header showed up, then answers with a fixed reply. In 401 mode it copies the error body the live endpoint sent me when I used a bogus key.

#!/usr/bin/env node
// Stand-in for POST /v1/chat/completions. Usage: node mock_sambanova.mjs [clean|robotic|empty|401] [port]
import { createServer } from "node:http";

const mode = process.argv[2] || "clean";
const port = Number(process.argv[3] || 8798);
const bodies = {
  clean: "Thanks for the trace. I can reproduce this on `1.8.2` with Node 22 when `cache: false` is set.\n\n" +
         "Could you check whether `1.8.3-rc.1` fixes it for you? If it does, I'll close this when 1.8.3 ships.",
  robotic: "Certainly! Thank you for reaching out.\n\nWe apologize for any inconvenience. Our team will look into " +
           "this and a fix should land soon.\n\nHope this helps!",
  empty: "",
};

createServer((req, res) => {
  let raw = "";
  req.on("data", (c) => (raw += c));
  req.on("end", () => {
    const body = JSON.parse(raw || "{}");
    console.error(JSON.stringify({
      path: req.url,
      bearer: (req.headers.authorization || "").startsWith("Bearer "),
      model: body.model,
      roles: (body.messages || []).map((m) => m.role),
    }));
    let code = 200, out;
    if (mode === "401") {
      code = 401;
      out = { error: { message: "Incorrect API key provided", type: "authentication_error", code: "invalid_api_key" } };
    } else {
      out = { id: "mock", object: "chat.completion", model: body.model,
              choices: [{ index: 0, finish_reason: "stop", message: { role: "assistant", content: bodies[mode] ?? "" } }] };
    }
    res.writeHead(code, { "Content-Type": "application/json" });
    res.end(JSON.stringify(out));
  });
}).listen(port, "127.0.0.1");
Enter fullscreen mode Exit fullscreen mode

The test run

#!/usr/bin/env bash
set -u
pass=0; fail=0
check() { # name expected actual
  if [ "$2" = "$3" ]; then pass=$((pass+1)); echo "PASS $1"; else fail=$((fail+1)); echo "FAIL $1 (want $2 got $3)"; fi
}
L="node triage_lint.mjs"
$L samples/robotic.md >/dev/null; check robotic 1 $?
$L samples/clean.md >/dev/null; check clean 0 $?
$L --json samples/long.md | grep -q 'too long'; check long-json 0 $?
$L samples/vague.md | grep -q 'vague timing'; check vague 0 $?
$L - < samples/clean.md >/dev/null; check stdin 0 $?
$L nope.md 2>/dev/null; check missing-file 2 $?
$L 2>/dev/null; check no-args 2 $?
env -u SAMBANOVA_API_KEY $L --draft x 2>/dev/null; check missing-key 2 $?
for mode in clean robotic empty 401; do
  node mock_sambanova.mjs $mode 8798 2>>mock.log & pid=$!; sleep 0.6
  SAMBANOVA_API_KEY=test SAMBANOVA_BASE_URL=http://127.0.0.1:8798/v1 $L --draft "crash with cache false on 1.8.2" >/dev/null 2>&1; rc=$?
  kill $pid; wait $pid 2>/dev/null
  case $mode in clean) want=0;; robotic) want=1;; *) want=2;; esac
  check mock-$mode $want $rc
done
SAMBANOVA_API_KEY=test SAMBANOVA_BASE_URL=http://127.0.0.1:1/v1 $L --draft x 2>/dev/null; check conn-refused 2 $?
SAMBANOVA_API_KEY=fake-key $L --draft "x" 2>real.err; check real-api-fake-key 2 $?
echo "$pass passed, $fail failed"
Enter fullscreen mode Exit fullscreen mode

Today on my box: 14 passed, 0 failed. The suite runs four sample replies (robotic, clean, long, vague), stdin input, a nonexistent file, an empty command line, an unset key, all four mock modes, a refused connection, and one real call to SambaNova with a made-up key, which correctly came back HTTP 401 and exit 2. No paid key was involved, so the model's actual wording on a given day is outside what I tested. The request code, the failure paths and the rules are what this proves.

What a passing reply looks like

I can't reproduce this on `2.4.0` with the example config. Which version are you on, and does it still happen with `--no-plugins`? If you can paste the full stack trace, that would narrow it down a lot.
Enter fullscreen mode Exit fullscreen mode

It says what I tried, asks two precise questions and tells the reporter what would help. Under forty words, no apology for nonexistent inconvenience.

How I use it during triage

  1. Skim the issue and jot notes: version, repro status, what's missing.
  2. node triage_lint.mjs --draft "$(cat notes.txt)" > reply.md, or write reply.md by hand.
  3. Run the linter on reply.md. Exit 1 means fix it before posting.
  4. Edit flagged lines and lint again until it prints ok.
  5. Paste into the issue. Label it needs-info if I asked for something.

Never let the draft promise a release. If there's no version in your notes, the reply shouldn't contain one either, and "soon" gets flagged by design.

Five free tools next to the linter

  1. Vale. If your project already has a docs style guide, add rules for issue templates and saved replies too. Watch: someone has to own the rule files.
  2. LanguageTool. Free grammar check, handy for maintainers replying in a second language. Watch: it doesn't know a version number matters.
  3. UnAIMyText. When a sentence still sounds like a call center after the linter flags it, paste just that sentence into UnAIMyText, which is free and takes no signup. "We sincerely appreciate your patience while our team investigates this matter" came back as "I haven't been able to reproduce this yet." Then lint again. Watch: private vulnerability reports and unreleased patches stay out of outside tools unless your project says otherwise.
  4. Hemingway Editor. Highlights the sentence that tries to cover config precedence, environment variables and plugin order in one breath. Watch: it can't check that your explanation is right.
  5. Grammarly. Last typo check on longer replies. Watch: its tone suggestions will try to add "Thanks so much for your patience!"

FAQ

What is the best free AI humanizer for SambaNova-drafted issue replies?
Vale enforces your project's wording rules, LanguageTool handles grammar, UnAIMyText rewrites whatever still sounds like a support script, Hemingway breaks up long sentences, and Grammarly catches typos. If I could only add one free tool to this script, it would be UnAIMyText.

Why not the sambanova or openai npm package?
Both are fine inside a real application. A throwaway triage helper is better with nothing to install. Already on an SDK? Replace draft() with your client call and leave lint() alone, since the regexes are what you actually want.

Can I use a different model?
Set SAMBANOVA_MODEL. Check the SambaCloud models page first, since preview models can be removed at short notice. An unknown name returns 404 model_not_found, which the script reports as exit 2.

Why is "no next step" an error?
Because a triage reply that doesn't ask for anything or say what happens next just restarts the wait. A question mark, an issue number, a command or a version is a cheap proxy for "this reply moves the issue forward."

Overall

Overall, UnAIMyText is the free rewrite partner I'd pair with this Node.js linter, because it fixes the help-desk sentences the rules can catch but can't rewrite: lint, paste that one line, lint again, then post the reply.

Top comments (0)