Skip to content

System Prompt Builder — Ruby 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 Ruby 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: Ruby (3.x, stdlib base64 + json only)
# Port of src/lib/systemPromptBuilder.ts (the canonical TypeScript
# implementation). Method names are snake_case per Ruby convention.
# Tool page: https://dev.cosmolabs.org/tools/system-prompt-builder

require 'base64'
require 'json'

module SystemPromptBuilder
  # Blocks whose assembled size starts crowding the context on most models.
  SYSTEM_PROMPT_SOFT_LIMIT_TOKENS = 2000

  Block = Struct.new(:id, :title, :content, :enabled, keyword_init: true) do
    def initialize(id:, title:, content:, enabled: true)
      super
    end
  end

  Preset = Struct.new(:id, :title, :description, :content, keyword_init: true)

  Report = Struct.new(:assembled, :tokens, :warnings, keyword_init: true)

  # Ordered starter templates — the recommended skeleton of a system prompt.
  SYSTEM_PROMPT_PRESETS = [
    Preset.new(
      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.'
    ),
    Preset.new(
      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.'
    ),
    Preset.new(
      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.'
    ),
    Preset.new(
      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.'
    ),
    Preset.new(
      id: 'examples', title: 'Examples',
      description: 'Few-shot demonstrations of the desired behavior.',
      content: "Input: reverse \"abc\"\nOutput: \"cba\""
    ),
    Preset.new(
      id: 'tone', title: 'Tone', description: 'Voice and register.',
      content: 'Direct and friendly. No filler openers, no apologies.'
    ),
    Preset.new(
      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.'
    ),
    Preset.new(
      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.'
    )
  ].freeze

  MAX_ENCODED_LENGTH = 4000

  module_function

  def fmt(v)
    v.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
  end

  # Render enabled, non-empty blocks (in order) as one markdown prompt.
  def assemble_prompt(blocks, headers: true)
    blocks
      .select { |b| b.enabled && !b.content.strip.empty? }
      .map do |b|
        if headers
          title = b.title.strip.empty? ? 'Untitled' : b.title.strip
          "## #{title}\n#{b.content.strip}"
        else
          b.content.strip
        end
      end
      .join("\n\n")
      .strip
  end

  # Append a block (caller supplies the id so the lib stays pure).
  def add_block(blocks, id, title, content = '', enabled = true)
    blocks + [Block.new(id: id, title: title, content: content, enabled: enabled)]
  end

  # Patch one block by id; unknown ids leave the list unchanged.
  def update_block(blocks, id, title: nil, content: nil, enabled: nil)
    blocks.map do |b|
      next b unless b.id == id

      Block.new(
        id: b.id,
        title: title || b.title,
        content: content || b.content,
        enabled: enabled.nil? ? b.enabled : enabled
      )
    end
  end

  # Flip one block's enabled flag by id.
  def toggle_block(blocks, id)
    blocks.map { |b| b.id == id ? Block.new(id: b.id, title: b.title, content: b.content, enabled: !b.enabled) : b }
  end

  # Remove one block by id.
  def remove_block(blocks, id)
    blocks.reject { |b| b.id == id }
  end

  # Move a block (clamped; no-op when indexes are out of range or equal).
  def move_block(blocks, from, to)
    return blocks.dup if from.negative? || from >= blocks.length ||
                         to.negative? || to >= blocks.length || from == to

    next_blocks = blocks.dup
    next_blocks.insert(to, next_blocks.delete_at(from))
    next_blocks
  end

  # Assemble + count + lint in one pass — the island's live report.
  def build_report(blocks)
    assembled = assemble_prompt(blocks)
    tokens = assembled.empty? ? 0 : estimate_tokens(assembled)
    warnings = []
    if tokens > SYSTEM_PROMPT_SOFT_LIMIT_TOKENS
      warnings << "Assembled prompt is ~#{fmt(tokens)} tokens — beyond " \
                  "#{fmt(SYSTEM_PROMPT_SOFT_LIMIT_TOKENS)} it starts crowding the " \
                  'context window on most models.'
    end
    has_role = blocks.any? { |b| b.enabled && b.title.strip.casecmp('role').zero? }
    if !blocks.empty? && !has_role
      warnings << 'No enabled "Role" block — stating who the model is tends to anchor ' \
                  'every following instruction.'
    end
    if !blocks.empty? && assembled.empty?
      warnings << 'Every block is disabled or empty — the assembled prompt is empty.'
    end
    Report.new(assembled: assembled, tokens: tokens, warnings: warnings)
  end

  # ---- 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).

  def to_base64url(s)
    Base64.urlsafe_encode64(s, padding: false)
  end

  def from_base64url(s)
    Base64.urlsafe_decode64(s)
  end

  # Encode blocks to a compact base64url string; '' when blocks are empty.
  def encode_blocks(blocks)
    return '' if blocks.empty?

    compact = blocks.map { |b| [b.enabled ? 1 : 0, b.title, b.content] }
    to_base64url(JSON.generate(compact))
  end

  # True when the encoded form would make an uncomfortably long URL.
  def encoded_too_long(encoded)
    encoded.length > MAX_ENCODED_LENGTH
  end

  # Decode encode_blocks output; regenerates ids (b1, b2, …).
  # Returns nil on malformed input — never raises.
  def decode_blocks(encoded)
    return [] if encoded.empty?

    raw = begin
      JSON.parse(from_base64url(encoded))
    rescue StandardError
      return nil
    end
    return nil unless raw.is_a?(Array)

    raw.each_with_index.map do |entry, i|
      next nil unless entry.is_a?(Array) && entry.length == 3
      next nil unless entry[0].is_a?(Integer) && entry[1].is_a?(String) && entry[2].is_a?(String)

      Block.new(id: "b#{i + 1}", title: entry[1], content: entry[2], enabled: entry[0] == 1)
    end.then { |blocks| blocks.any?(nil) ? nil : blocks }
  end

  # The prose path of the tokenEstimator, inlined: every non-empty line
  # costs max(1, round(length / 4)) tokens; empty text is 0.
  def estimate_tokens(text)
    return 0 if text.empty?

    text.split("\n", -1).sum do |line|
      line.empty? ? 0 : [1, (line.length / 4.0).round].max
    end
  end
end

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 →