Skip to content

System Prompt Builder — JavaScript source

Assemble a system prompt from ordered blocks — role, context, constraints, output format — with a live token count, soft-limit warnings, and a shareable URL. 100% client-side.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

/**
 * System Prompt Builder — assemble an ordered list of prompt blocks into a
 * markdown-structured system prompt, with pure list operations, presets,
 * warnings, and a compact URL codec for shareable state.
 *
 * Language: JavaScript (ES2022+, ES module; runs unmodified in Node 18+
 *           and modern browsers)
 * Source:   CosmoDev polyglot showcase port of the System Prompt Builder
 *           tool, ported from src/lib/systemPromptBuilder.ts (the canonical
 *           TypeScript implementation).
 * Tool page: https://dev.cosmolabs.org/tools/system-prompt-builder
 * License:  display source — part of CosmoDev's polyglot tool pages.
 *
 * Token counting inlines the chars-per-token heuristic from
 * src/lib/tokenEstimator.ts (the original imports it); the soft-limit
 * constant lives here as the domain rule.
 */

/** One editable section of the system prompt. */
// interface PromptBlock { id, title, content, enabled }
// interface PromptPreset { id, title, description, content }
// interface PromptReport { assembled, tokens, warnings }

/** Blocks whose assembled size starts crowding the context on most models. */
export const SYSTEM_PROMPT_SOFT_LIMIT_TOKENS = 2000;

/** Ordered starter templates — the recommended skeleton of a system prompt. */
export const SYSTEM_PROMPT_PRESETS = [
  {
    id: 'role',
    title: 'Role',
    description: 'Who the model is and what it optimizes for.',
    content:
      'You are a senior software engineer. You give correct, concise answers and say so plainly when you are unsure.',
  },
  {
    id: 'context',
    title: 'Context',
    description: 'The situation the model is working in.',
    content:
      'The user is a developer working in a TypeScript codebase. Prefer runnable examples over prose when both work.',
  },
  {
    id: 'constraints',
    title: 'Constraints',
    description: 'Hard rules the model must not break.',
    content: '- Never invent library APIs; use only the ones in the provided code.\n- Keep answers under 300 words unless asked for more.',
  },
  {
    id: 'output-format',
    title: 'Output format',
    description: 'The exact shape of the answer.',
    content: 'Respond with: 1) a one-line summary, 2) a fenced code block, 3) any caveats as bullet points.',
  },
  {
    id: 'examples',
    title: 'Examples',
    description: 'Few-shot demonstrations of the desired behavior.',
    content: 'Input: reverse "abc"\nOutput: "cba"',
  },
  {
    id: 'tone',
    title: 'Tone',
    description: 'Voice and register.',
    content: 'Direct and friendly. No filler openers, no apologies.',
  },
  {
    id: 'refusal',
    title: 'Refusal policy',
    description: 'How to handle out-of-scope requests.',
    content: 'If a request is outside your scope, say so in one sentence and suggest the closest thing you can do.',
  },
  {
    id: 'safety',
    title: 'Safety',
    description: 'Guardrails for sensitive content.',
    content: 'Refuse requests that could cause harm, and never echo secrets, keys, or credentials back in full.',
  },
];

// ---- token estimate (tokens figure only, from tokenEstimator.ts) ------------

const CHARS_PER_TOKEN = { prose: 4, code: 3.5, json: 3, cjk: 1.5 };
const CJK_RE = /[一-鿿぀-ヿ가-힯]/;
const CODE_SYMBOL_RE = /[{}();=<>\[\]#]/g;

/** Classify a single line by its shape. Order: json, cjk, code, prose. */
function detectLineType(line) {
  const trimmed = line.trim();
  // JSON-ish: opens like a JSON fragment AND carries a separator.
  if ((trimmed.startsWith('{') || trimmed.startsWith('}') || trimmed.startsWith('[') || trimmed.startsWith('"')) && (line.includes(':') || line.includes(','))) {
    return 'json';
  }
  // CJK ideographs / kana / Hangul pack roughly one token per 1.5 chars.
  if (CJK_RE.test(line)) return 'cjk';
  // Code: symbol-dense, or a statement terminator / block opener at EOL.
  const density = (line.match(CODE_SYMBOL_RE) ?? []).length / line.length;
  if (density > 0.08 || trimmed.endsWith(';') || trimmed.endsWith('{') || trimmed.endsWith('}')) {
    return 'code';
  }
  return 'prose';
}

/** Sum of per-line token estimates (excludes chat framing). `type` may be
 *  'prose' | 'code' | 'json' | 'cjk' | 'auto'; 'auto' classifies per line,
 *  with a document that parses as JSON counted as json throughout. */
function estimateTokens(text, type = 'prose') {
  const forced = type && type !== 'auto' ? type : null;
  let wholeTextJson = false;
  if (forced === null && text.trim()) {
    try {
      JSON.parse(text);
      wholeTextJson = true;
    } catch {
      /* not JSON — classify line by line */
    }
  }
  let tokens = 0;
  for (const line of text.split(/\r?\n/)) {
    if (line.trim() === '') continue;
    const t = forced ?? (wholeTextJson ? 'json' : detectLineType(line));
    tokens += Math.max(1, Math.round(line.length / CHARS_PER_TOKEN[t]));
  }
  return tokens;
}

/** Render enabled, non-empty blocks (in order) as one markdown-structured prompt. */
export function assemblePrompt(blocks, opts = {}) {
  const { headers = true } = opts;
  return blocks
    .filter((b) => b.enabled && b.content.trim().length > 0)
    .map((b) => (headers ? `## ${b.title.trim() || 'Untitled'}\n${b.content.trim()}` : b.content.trim()))
    .join('\n\n')
    .trim();
}

/** Append a block (caller supplies the id so the lib stays pure). */
export function addBlock(blocks, id, title, content = '', enabled = true) {
  return [...blocks, { id, title, content, enabled }];
}

/** Patch one block by id; unknown ids leave the list unchanged. */
export function updateBlock(blocks, id, patch) {
  return blocks.map((b) => (b.id === id ? { ...b, ...patch } : b));
}

/** Flip one block's enabled flag by id. */
export function toggleBlock(blocks, id) {
  return blocks.map((b) => (b.id === id ? { ...b, enabled: !b.enabled } : b));
}

/** Remove one block by id. */
export function removeBlock(blocks, id) {
  return blocks.filter((b) => b.id !== id);
}

/** Move a block (clamped; no-op when indexes are out of range or equal). */
export function moveBlock(blocks, from, to) {
  if (from < 0 || from >= blocks.length || to < 0 || to >= blocks.length || from === to) {
    return [...blocks];
  }
  const next = [...blocks];
  const [moved] = next.splice(from, 1);
  next.splice(to, 0, moved);
  return next;
}

/** Assemble + count + lint in one pass — the island's live report. */
export function buildReport(blocks, contentType = 'prose') {
  const assembled = assemblePrompt(blocks);
  const tokens = assembled ? estimateTokens(assembled, contentType) : 0;
  const warnings = [];
  if (tokens > SYSTEM_PROMPT_SOFT_LIMIT_TOKENS) {
    warnings.push(
      `Assembled prompt is ~${tokens.toLocaleString('en-US')} tokens — beyond ${SYSTEM_PROMPT_SOFT_LIMIT_TOKENS.toLocaleString('en-US')} it starts crowding the context window on most models.`,
    );
  }
  if (blocks.length > 0 && !blocks.some((b) => b.enabled && b.title.trim().toLowerCase() === 'role')) {
    warnings.push('No enabled "Role" block — stating who the model is tends to anchor every following instruction.');
  }
  if (blocks.length > 0 && assembled === '') {
    warnings.push('Every block is disabled or empty — the assembled prompt is empty.');
  }
  return { assembled, tokens, warnings };
}

// ---- shareable state codec (URL-safe, compact) ------------------------------
// Triples of [enabled(0/1), title, content] keep URLs far smaller than the
// full object shape; ids are regenerated on decode (they are UI-local).

const MAX_ENCODED_LENGTH = 4000;

function toBase64Url(s) {
  const bytes = new TextEncoder().encode(s);
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function fromBase64Url(s) {
  const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4);
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

/** Encode blocks to a compact base64url string; '' when blocks are empty. */
export function encodeBlocks(blocks) {
  if (blocks.length === 0) return '';
  const compact = blocks.map((b) => [b.enabled ? 1 : 0, b.title, b.content]);
  return toBase64Url(JSON.stringify(compact));
}

/** True when the encoded form would make an uncomfortably long URL. */
export function encodedTooLong(encoded) {
  return encoded.length > MAX_ENCODED_LENGTH;
}

/** Decode `encodeBlocks` output; regenerates ids (b1, b2, …). Returns null on
 *  malformed input — never throws. */
export function decodeBlocks(encoded) {
  if (!encoded) return [];
  try {
    const raw = JSON.parse(fromBase64Url(encoded));
    if (!Array.isArray(raw)) return null;
    const blocks = [];
    for (const [i, entry] of raw.entries()) {
      if (!Array.isArray(entry) || entry.length !== 3) return null;
      const [enabled, title, content] = entry;
      if (typeof enabled !== 'number' || typeof title !== 'string' || typeof content !== 'string') {
        return null;
      }
      blocks.push({ id: `b${i + 1}`, title, content, enabled: enabled === 1 });
    }
    return blocks;
  } catch {
    return null;
  }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →