System Prompt Builder — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package systempromptbuilder is the Go twin of CosmoDev's
// src/lib/systemPromptBuilder.ts (dual source: the web lib is TypeScript, the
// CLI lib is Go — kept in lock-step). Pure + deterministic, never panics. The
// table-driven tests in systempromptbuilder_test.go share vectors with
// src/lib/systemPromptBuilder.test.ts so the two implementations are held to
// the same contract.
//
// The package assembles 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. Token counting
// reuses the token-estimator twin at the island layer; the soft-limit constant
// lives here as the domain rule.
package systempromptbuilder
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"strconv"
"strings"
tokenestimator "cosmodev/token-estimator"
)
// PromptBlock is one section of a system prompt. Field-for-field twin of the
// TS PromptBlock interface (camelCase JSON tags, aimodels convention).
type PromptBlock struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Enabled bool `json:"enabled"`
}
// PromptPreset is one ordered starter template. Mirrors the TS PromptPreset
// interface.
type PromptPreset struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
}
// SystemPromptSoftLimitTokens is the assembled size beyond which blocks start
// crowding the context on most models. Mirrors SYSTEM_PROMPT_SOFT_LIMIT_TOKENS.
const SystemPromptSoftLimitTokens = 2000
// SystemPromptPresets are the ordered starter templates — the recommended
// skeleton of a system prompt. Mirrors SYSTEM_PROMPT_PRESETS.
var SystemPromptPresets = []PromptPreset{
{
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.",
},
}
// AssembleOptions configures AssemblePrompt. The zero value (AssembleOptions{})
// matches the TS default (assemblePrompt(blocks) with no options): headers on.
//
// Headers is a *bool so the zero value means "default true" — exactly like the
// TS lib's distinction between omitted (→ true) and false (→ raw contents).
type AssembleOptions struct {
Headers *bool // nil → true (default); non-nil used verbatim
}
// BlockPatch is the partial update applied by UpdateBlock — the twin of TS's
// Partial<Omit<PromptBlock, 'id'>>. A nil field leaves that field untouched.
type BlockPatch struct {
Title *string
Content *string
Enabled *bool
}
// AssemblePrompt renders enabled, non-empty blocks (in order) as one
// markdown-structured prompt. It is the Go twin of assemblePrompt() in
// src/lib/systemPromptBuilder.ts and must agree with it on every shared vector.
func AssemblePrompt(blocks []PromptBlock, opts AssembleOptions) string {
headers := true
if opts.Headers != nil {
headers = *opts.Headers
}
parts := make([]string, 0, len(blocks))
for _, b := range blocks {
if !b.Enabled || strings.TrimSpace(b.Content) == "" {
continue
}
if headers {
title := strings.TrimSpace(b.Title)
if title == "" {
title = "Untitled"
}
parts = append(parts, "## "+title+"\n"+strings.TrimSpace(b.Content))
} else {
parts = append(parts, strings.TrimSpace(b.Content))
}
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
// AddBlock appends a block (the caller supplies the id so the lib stays pure).
// The trailing bool mirrors TS's enabled = true default; pass it explicitly.
func AddBlock(blocks []PromptBlock, id, title, content string, enabled bool) []PromptBlock {
next := make([]PromptBlock, len(blocks), len(blocks)+1)
copy(next, blocks)
return append(next, PromptBlock{ID: id, Title: title, Content: content, Enabled: enabled})
}
// UpdateBlock patches one block by id; unknown ids leave the list unchanged.
func UpdateBlock(blocks []PromptBlock, id string, patch BlockPatch) []PromptBlock {
next := make([]PromptBlock, len(blocks))
copy(next, blocks)
for i := range next {
if next[i].ID != id {
continue
}
if patch.Title != nil {
next[i].Title = *patch.Title
}
if patch.Content != nil {
next[i].Content = *patch.Content
}
if patch.Enabled != nil {
next[i].Enabled = *patch.Enabled
}
}
return next
}
// ToggleBlock flips one block's enabled flag by id.
func ToggleBlock(blocks []PromptBlock, id string) []PromptBlock {
next := make([]PromptBlock, len(blocks))
copy(next, blocks)
for i := range next {
if next[i].ID == id {
next[i].Enabled = !next[i].Enabled
}
}
return next
}
// RemoveBlock removes one block by id.
func RemoveBlock(blocks []PromptBlock, id string) []PromptBlock {
next := make([]PromptBlock, 0, len(blocks))
for _, b := range blocks {
if b.ID != id {
next = append(next, b)
}
}
return next
}
// MoveBlock moves a block (clamped; a copy of the input when indexes are out
// of range or equal).
func MoveBlock(blocks []PromptBlock, from, to int) []PromptBlock {
if from < 0 || from >= len(blocks) || to < 0 || to >= len(blocks) || from == to {
out := make([]PromptBlock, len(blocks))
copy(out, blocks)
return out
}
next := make([]PromptBlock, len(blocks))
copy(next, blocks)
moved := next[from]
next = append(next[:from], next[from+1:]...)
rest := make([]PromptBlock, 0, len(next)+1)
rest = append(rest, next[:to]...)
rest = append(rest, moved)
rest = append(rest, next[to:]...)
return rest
}
// PromptReport is the assembled prompt plus its lint results. Field-for-field
// twin of the TS PromptReport interface.
type PromptReport struct {
Assembled string `json:"assembled"`
Tokens int `json:"tokens"`
Warnings []string `json:"warnings"`
}
// BuildReport assembles + counts + lints in one pass — the island's live
// report. The zero contentType ("") means "prose", the TS default; pass
// tokenestimator.Auto for explicit auto-detection.
func BuildReport(blocks []PromptBlock, contentType tokenestimator.AutoType) PromptReport {
// The TS default parameter is 'prose' (not auto). AutoType's Go zero
// value is "", so map it to prose; an explicit "auto" is passed through
// and detected per line by the estimator.
ct := contentType
if ct == "" {
ct = tokenestimator.TypeProse
}
assembled := AssemblePrompt(blocks, AssembleOptions{})
tokens := 0
if assembled != "" {
tokens = tokenestimator.EstimateTokens(assembled, &tokenestimator.EstimateOptions{ContentType: ct}).Tokens
}
warnings := []string{}
if tokens > SystemPromptSoftLimitTokens {
warnings = append(warnings,
"Assembled prompt is ~"+groupDigits(tokens)+" tokens — beyond "+groupDigits(SystemPromptSoftLimitTokens)+
" it starts crowding the context window on most models.")
}
if len(blocks) > 0 && !hasEnabledRole(blocks) {
warnings = append(warnings,
`No enabled "Role" block — stating who the model is tends to anchor every following instruction.`)
}
if len(blocks) > 0 && assembled == "" {
warnings = append(warnings, "Every block is disabled or empty — the assembled prompt is empty.")
}
return PromptReport{Assembled: assembled, Tokens: tokens, Warnings: warnings}
}
func hasEnabledRole(blocks []PromptBlock) bool {
for _, b := range blocks {
if b.Enabled && strings.ToLower(strings.TrimSpace(b.Title)) == "role" {
return true
}
}
return false
}
// groupDigits renders n with en-US thousands separators, the format TS's
// toLocaleString('en-US') produces (2000 → "2,000").
func groupDigits(n int) string {
s := strconv.Itoa(n)
sign := ""
if strings.HasPrefix(s, "-") {
sign, s = "-", s[1:]
}
if len(s) <= 3 {
return sign + s
}
var groups []string
for len(s) > 3 {
groups = append([]string{s[len(s)-3:]}, groups...)
s = s[:len(s)-3]
}
return sign + s + "," + strings.Join(groups, ",")
}
// ---- 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).
// maxEncodedLength is the length beyond which the encoded form would make an
// uncomfortably long URL. Mirrors MAX_ENCODED_LENGTH.
const maxEncodedLength = 4000
// ErrMalformedState is returned by DecodeBlocks on any malformed input — the
// Go stand-in for the TS lib's null return (never throws).
var ErrMalformedState = errors.New("malformed encoded block state")
func toBase64URL(s string) string {
// RawURLEncoding is unpadded and URL-safe: exactly the TS pipeline
// (btoa → replace +→- , /→_ , strip trailing '=').
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
func fromBase64URL(s string) (string, error) {
// Mirror of the TS pipeline: restore the standard alphabet, then pad.
std := strings.NewReplacer("-", "+", "_", "/").Replace(strings.TrimRight(s, "="))
b, err := base64.RawStdEncoding.DecodeString(std)
if err != nil {
return "", err
}
return string(b), nil
}
// EncodeBlocks encodes blocks to a compact base64url string; "" when blocks
// are empty.
func EncodeBlocks(blocks []PromptBlock) string {
if len(blocks) == 0 {
return ""
}
compact := make([][]any, 0, len(blocks))
for _, b := range blocks {
enabled := 0
if b.Enabled {
enabled = 1
}
compact = append(compact, []any{enabled, b.Title, b.Content})
}
// SetEscapeHTML(false) keeps the JSON byte-identical to JSON.stringify,
// so the encoded strings match the TS lib character for character.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(compact); err != nil {
return ""
}
return toBase64URL(strings.TrimSuffix(buf.String(), "\n"))
}
// EncodedTooLong reports whether the encoded form would make an
// uncomfortably long URL.
func EncodedTooLong(encoded string) bool {
return len(encoded) > maxEncodedLength
}
// DecodeBlocks reverses EncodeBlocks; it regenerates ids (b1, b2, …) and
// returns ErrMalformedState on malformed input — never panics.
func DecodeBlocks(encoded string) ([]PromptBlock, error) {
if encoded == "" {
return []PromptBlock{}, nil
}
raw, err := fromBase64URL(encoded)
if err != nil {
return nil, ErrMalformedState
}
var top any
if err := json.Unmarshal([]byte(raw), &top); err != nil {
return nil, ErrMalformedState
}
arr, ok := top.([]any)
if !ok {
return nil, ErrMalformedState
}
blocks := make([]PromptBlock, 0, len(arr))
for i, item := range arr {
entry, ok := item.([]any)
if !ok || len(entry) != 3 {
return nil, ErrMalformedState
}
enabledNum, ok := entry[0].(float64)
if !ok {
return nil, ErrMalformedState
}
title, ok := entry[1].(string)
if !ok {
return nil, ErrMalformedState
}
content, ok := entry[2].(string)
if !ok {
return nil, ErrMalformedState
}
blocks = append(blocks, PromptBlock{
ID: "b" + strconv.Itoa(i+1),
Title: title,
Content: content,
Enabled: enabledNum == 1,
})
}
return blocks, nil
}
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 →