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 →