Skip to content

System Prompt Builder — Zig 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 Zig 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: Zig (0.13+, stdlib only — std.json + std.base64)
// Port of src/lib/systemPromptBuilder.ts (the canonical TypeScript
// implementation). All returned strings/slices are allocated from the
// caller's allocator.
//
// Tool page: https://dev.cosmolabs.org/tools/system-prompt-builder

const std = @import("std");

/// Blocks whose assembled size starts crowding the context on most models.
pub const SYSTEM_PROMPT_SOFT_LIMIT_TOKENS: i64 = 2000;

pub const Block = struct {
    id: []const u8,
    title: []const u8,
    content: []const u8,
    enabled: bool = true,
};

pub const Preset = struct {
    id: []const u8,
    title: []const u8,
    description: []const u8,
    content: []const u8,
};

pub const Report = struct {
    assembled: []const u8, // allocated
    tokens: i64,
    warnings: [][]const u8, // each allocated

    pub fn free(self: *Report, alloc: std.mem.Allocator) void {
        alloc.free(self.assembled);
        for (self.warnings) |w| alloc.free(w);
        alloc.free(self.warnings);
    }
};

/// Ordered starter templates — the recommended skeleton of a system prompt.
pub const SYSTEM_PROMPT_PRESETS = [_]Preset{
    .{ .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." },
};

/// The prose path of the tokenEstimator, inlined: every non-empty line
/// costs max(1, round(length / 4)) tokens; empty text is 0.
pub fn estimateTokens(text: []const u8) i64 {
    if (text.len == 0) return 0;
    var tokens: i64 = 0;
    var it = std.mem.splitScalar(u8, text, '\n');
    while (it.next()) |line| {
        if (line.len > 0) {
            const per: i64 = @intFromFloat(@as(f64, @floatFromInt(line.len)) / 4.0 + 0.5);
            tokens += @max(1, per);
        }
    }
    return tokens;
}

fn trim(s: []const u8) []const u8 {
    return std.mem.trim(u8, s, " \t\r\n");
}

/// Render enabled, non-empty blocks (in order) as one markdown prompt.
/// Caller owns the returned string.
pub fn assemblePrompt(alloc: std.mem.Allocator, blocks: []const Block, headers: bool) ![]const u8 {
    var out: std.ArrayList(u8) = .init(alloc);
    errdefer out.deinit();
    var first = true;
    for (blocks) |b| {
        const content = trim(b.content);
        if (!b.enabled or content.len == 0) continue;
        if (!first) try out.appendSlice("\n\n");
        if (headers) {
            const title = trim(b.title);
            try out.writer().print("## {s}\n{s}", .{
                if (title.len == 0) "Untitled" else title, content,
            });
        } else {
            try out.appendSlice(content);
        }
        first = false;
    }
    return out.toOwnedSlice();
}

/// Append a block (caller supplies the id so the lib stays pure).
/// Caller owns the returned slice.
pub fn addBlock(alloc: std.mem.Allocator, blocks: []const Block, id: []const u8,
    title: []const u8, content: []const u8, enabled: bool) ![]Block
{
    const next = try alloc.alloc(Block, blocks.len + 1);
    @memcpy(next[0..blocks.len], blocks);
    next[blocks.len] = .{
        .id = try alloc.dupe(u8, id),
        .title = try alloc.dupe(u8, title),
        .content = try alloc.dupe(u8, content),
        .enabled = enabled,
    };
    return next;
}

/// Move a block (clamped; no-op when indexes are out of range or equal).
/// Caller owns the returned slice (ids/titles/contents are duplicated).
pub fn moveBlock(alloc: std.mem.Allocator, blocks: []const Block, from: usize, to: usize) ![]Block {
    if (from >= blocks.len or to >= blocks.len or from == to) {
        return dupeBlocks(alloc, blocks);
    }
    const next = try alloc.alloc(Block, blocks.len);
    @memcpy(next, blocks);
    const moved = next[from];
    if (to > from) {
        var i = from;
        while (i < to) : (i += 1) next[i] = next[i + 1];
    } else {
        var i = from;
        while (i > to) : (i -= 1) next[i] = next[i - 1];
    }
    next[to] = moved;
    return next;
}

fn dupeBlocks(alloc: std.mem.Allocator, blocks: []const Block) ![]Block {
    const out = try alloc.alloc(Block, blocks.len);
    for (blocks, 0..) |b, i| {
        out[i] = .{
            .id = try alloc.dupe(u8, b.id),
            .title = try alloc.dupe(u8, b.title),
            .content = try alloc.dupe(u8, b.content),
            .enabled = b.enabled,
        };
    }
    return out;
}

/// Assemble + count + lint in one pass — the island's live report.
pub fn buildReport(alloc: std.mem.Allocator, blocks: []const Block) !Report {
    const assembled = try assemblePrompt(alloc, blocks, true);
    errdefer alloc.free(assembled);
    const tokens: i64 = if (assembled.len > 0) estimateTokens(assembled) else 0;
    var warnings: std.ArrayList([]const u8) = .init(alloc);
    errdefer {
        for (warnings.items) |w| alloc.free(w);
        warnings.deinit();
    }
    if (tokens > SYSTEM_PROMPT_SOFT_LIMIT_TOKENS) {
        try warnings.append(try std.fmt.allocPrint(alloc,
            "Assembled prompt is ~{d} tokens — beyond {d} it starts crowding the context window on most models.",
            .{ tokens, SYSTEM_PROMPT_SOFT_LIMIT_TOKENS }));
    }
    var has_role = false;
    for (blocks) |b| {
        if (b.enabled and std.ascii.eqlIgnoreCase(trim(b.title), "role")) {
            has_role = true;
            break;
        }
    }
    if (blocks.len > 0 and !has_role) {
        try warnings.append(try alloc.dupe(u8,
            "No enabled \"Role\" block — stating who the model is tends to anchor every following instruction."));
    }
    if (blocks.len > 0 and assembled.len == 0) {
        try warnings.append(try alloc.dupe(u8,
            "Every block is disabled or empty — the assembled prompt is empty."));
    }
    return .{
        .assembled = assembled,
        .tokens = tokens,
        .warnings = try warnings.toOwnedSlice(),
    };
}

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

pub const MAX_ENCODED_LENGTH: usize = 4000;

/// JSON string escaping for the compact codec.
const enc = struct {
    fn writeString(w: anytype, s: []const u8) !void {
        try w.writeByte('"');
        for (s) |c| {
            switch (c) {
                '"' => try w.writeAll("\\\""),
                '\\' => try w.writeAll("\\\\"),
                '\n' => try w.writeAll("\\n"),
                '\r' => try w.writeAll("\\r"),
                '\t' => try w.writeAll("\\t"),
                else => {
                    if (c < 0x20) {
                        try w.print("\\u{x:0>4}", .{c});
                    } else {
                        try w.writeByte(c);
                    }
                },
            }
        }
        try w.writeByte('"');
    }
};

/// Encode blocks to a compact base64url string; "" when blocks are empty.
/// Caller owns the returned string.
pub fn encodeBlocks(alloc: std.mem.Allocator, blocks: []const Block) ![]const u8 {
    if (blocks.len == 0) return try alloc.dupe(u8, "");
    var json: std.ArrayList(u8) = .init(alloc);
    defer json.deinit();
    try json.append('[');
    for (blocks, 0..) |b, i| {
        if (i > 0) try json.append(',');
        try json.writer().print("[{d},", .{@as(u8, if (b.enabled) 1 else 0)});
        try enc.writeString(json.writer(), b.title);
        try json.append(',');
        try enc.writeString(json.writer(), b.content);
        try json.append(']');
    }
    try json.append(']');

    const enc64 = std.base64.url_safe_no_pad.Encoder;
    const size = enc64.calcSize(json.items.len);
    const out = try alloc.alloc(u8, size);
    _ = enc64.encode(out, json.items);
    return out;
}

/// True when the encoded form would make an uncomfortably long URL.
pub fn encodedTooLong(encoded: []const u8) bool {
    return encoded.len > MAX_ENCODED_LENGTH;
}

/// Decode `encodeBlocksReal` output; regenerates ids ("b1", "b2", …).
/// Returns null on malformed input — never throws. Caller owns the result
/// (free each block's fields + the slice with freeBlocks).
pub fn decodeBlocks(alloc: std.mem.Allocator, encoded: []const u8) !?[]Block {
    if (encoded.len == 0) return try alloc.alloc(Block, 0);
    const dec64 = std.base64.url_safe_no_pad.Decoder;
    const size = dec64.calcSizeForSlice(encoded) catch return null;
    const json_buf = try alloc.alloc(u8, size);
    defer alloc.free(json_buf);
    dec64.decode(json_buf, encoded) catch return null;

    const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_buf, .{}) catch return null;
    defer parsed.deinit();
    if (parsed.value != .array) return null;

    var blocks: std.ArrayList(Block) = .init(alloc);
    errdefer {
        for (blocks.items) |b| freeBlock(alloc, b);
        blocks.deinit();
    }
    for (parsed.value.array.items, 0..) |entry, i| {
        if (entry != .array or entry.array.items.len != 3) return null;
        const enabled_v = entry.array.items[0];
        const title_v = entry.array.items[1];
        const content_v = entry.array.items[2];
        if (enabled_v != .integer or title_v != .string or content_v != .string) return null;
        try blocks.append(.{
            .id = try std.fmt.allocPrint(alloc, "b{d}", .{i + 1}),
            .title = try alloc.dupe(u8, title_v.string),
            .content = try alloc.dupe(u8, content_v.string),
            .enabled = enabled_v.integer == 1,
        });
    }
    return try blocks.toOwnedSlice();
}

fn freeBlock(alloc: std.mem.Allocator, b: Block) void {
    alloc.free(b.id);
    alloc.free(b.title);
    alloc.free(b.content);
}

/// Free a decoded block list.
pub fn freeBlocks(alloc: std.mem.Allocator, blocks: []Block) void {
    for (blocks) |b| freeBlock(alloc, b);
    alloc.free(blocks);
}

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 →