Skip to content

Budget the base prompt by section (and fix the custom-rules framing)#498

Merged
FuJacob merged 1 commit into
mainfrom
experimental/m1-budgeted-prompt
Jun 1, 2026
Merged

Budget the base prompt by section (and fix the custom-rules framing)#498
FuJacob merged 1 commit into
mainfrom
experimental/m1-budgeted-prompt

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented Jun 1, 2026

Replaces the flat-prose base prompt with a character-budgeted sectioned builder (PromptSectionBudget). Each section (persona, style, language, notes, clipboard, screen, caret prefix) declares a priority + min/max budget; the allocator fills highest-priority-first within a total budget, so a large glossary/clipboard/screen capture can't starve the caret text (top priority, guaranteed minimum, kept by its end). Also fixes the custom-rules framing bug: rules render as their own Writing style: ... line instead of in a <rules> style (which broke for sentence-shaped rules). Validation: build-for-testing BUILD SUCCEEDED, swiftlint clean, 9 new PromptSectionBudget tests + updated renderer test. Foundation for the OCR + writing-history context sections.

Greptile Summary

Replaces the flat-prose base-prompt builder with a character-budgeted section allocator (PromptSectionBudget) and fixes the custom-rules conditioning line so sentence-shaped rules render correctly. Sections (persona, style, language, notes, clipboard, screen, prefix) each carry a priority and min/max char budget; allocate fills highest-priority-first so the caret prefix is never starved.

  • PromptSectionBudget.swift is a new pure-function allocator; well-tested across 9 cases covering priority fill, budget cap, render-order preservation, minChars drop, and truncation modes.
  • BaseCompletionPromptRenderer.swift wires the allocator in and fixes the \"in a X style\" framing bug by rendering custom rules as a standalone \"Writing style: \u2026\" line.
  • Two minor edge cases: a stale doc comment names enum cases that no longer exist, and the minChars guard fires against the pre-trim character count rather than the post-trim length.

Confidence Score: 4/5

Safe to merge; the new allocator is well-isolated, well-tested, and the default budget makes the identified edge cases practically unreachable in normal use.

The refactor is clean and the core priority-fill logic is correct. The two findings are both edge cases: a stale doc comment and a whitespace-trimming quirk on the prefix that only fires when the total budget is smaller than the already-windowed prefix.

Both PromptSectionBudget.swift and BaseCompletionPromptRenderer.swift carry minor issues worth a second look before extending the allocator with new sections.

Important Files Changed

Filename Overview
Cotabby/Support/PromptSectionBudget.swift New file: pure character-budget allocator. Logic is sound; two minor issues — a stale doc comment naming non-existent enum cases, and minChars being checked against the pre-whitespace-trim cap rather than the final character count.
Cotabby/Support/BaseCompletionPromptRenderer.swift Refactored from flat-prose to section-budgeted builder; also fixes the "in a X style" framing bug. One edge case: trimmingCharacters on the truncated prefix end could strip a meaningful leading space when the budget is tighter than the prefix length.
CotabbyTests/PromptSectionBudgetTests.swift 9 new tests covering priority fill, budget cap, render-order preservation, minChars drop, whitespace-only drop, and truncation modes.
CotabbyTests/BaseCompletionPromptRendererTests.swift Updated one assertion to match the new capitalised "Written by" framing; existing tests still exercise all major rendering contracts.
Cotabby.xcodeproj/project.pbxproj Adds PromptSectionBudget.swift to the main target and PromptSectionBudgetTests.swift to the test target; project file changes look correct.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Renderer as BaseCompletionPromptRenderer
    participant Budget as PromptSectionBudget

    Caller->>Renderer: prompt(prefixText, userName, customRules, ...)
    Renderer->>Renderer: trimmingTrailingWhitespace(prefixText)
    Renderer->>Renderer: Build PromptSection list
    Renderer->>Budget: allocate(sections, totalChars: 2400)
    Budget->>Budget: Sort by priority descending
    loop each section in priority order
        Budget->>Budget: "cap = min(maxChars, content.count, remaining)"
        alt "cap < minChars"
            Budget->>Budget: drop section
        else
            Budget->>Budget: truncate then trim then guard non-empty
            Budget->>Budget: "remaining -= truncated.count"
        end
    end
    Budget-->>Renderer: kept sections in original order
    Renderer->>Renderer: Extract prefix and preface
    alt preface is empty
        Renderer-->>Caller: prefix only
    else
        Renderer-->>Caller: preface joined + blank line + prefix
    end
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Budget the base prompt by section so con..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Adds PromptSectionBudget, a pure character-budget allocator, and rewrites BaseCompletionPromptRenderer to build prioritized sections (persona, style, language, notes, clipboard, screen, and the caret prefix) allocated within a total budget. The caret prefix gets top priority, a guaranteed minimum, and preserve-end truncation, so a large glossary/clipboard/screen capture can never crowd it out.

Also fixes a framing bug: custom rules now render as their own 'Writing style: ...' line instead of being jammed into 'in a <rules> style', which produced broken prose like 'in a Use British spelling style' for sentence-shaped rules. Sets up the OCR and writing-history context sections.
@FuJacob FuJacob merged commit bbbc530 into main Jun 1, 2026
4 checks passed
Comment on lines +18 to +19
/// Which end of the content to keep when it must be shortened. `beforeCursor` keeps its END
/// (the text nearest the caret); `afterCursor` keeps its START (the text nearest the caret).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The doc comment for Truncation names two cases (beforeCursor, afterCursor) that don't exist in the enum — the actual cases are preserveStart and preserveEnd. A reader following the comment to understand which case to use will be confused, and any future tooling that cross-references doc names will silently mis-match.

Suggested change
/// Which end of the content to keep when it must be shortened. `beforeCursor` keeps its END
/// (the text nearest the caret); `afterCursor` keeps its START (the text nearest the caret).
/// Which end of the content to keep when it must be shortened. `preserveEnd` keeps the END
/// of the string (the text nearest the caret); `preserveStart` keeps the HEAD.

Fix in Codex Fix in Claude Code

Comment on lines +55 to +66
if cap < section.minChars {
continue
}
let truncated = truncate(section.content, toChars: cap, mode: section.truncation)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !truncated.isEmpty else {
continue
}
var copy = section
copy.content = truncated
kept[index] = copy
remaining -= truncated.count
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 minChars guard checked against pre-trim cap, not post-trim length

The contract in the doc comment says a section is dropped when "the remaining budget can't fit at least this many characters," but cap is computed from the raw (untrimmed) content. After truncate + trimmingCharacters, truncated.count can be smaller than cap — so a section that passes cap >= minChars may end up included with fewer real characters than the minimum. In practice only the prefix uses minChars: 1 and its content is already trailing-trimmed, but if context sections are ever given a non-zero minChars, this mismatch silently violates the contract.

Fix in Codex Fix in Claude Code

Comment on lines +57 to +69
sections.append(
PromptSection(
name: "prefix",
content: trimmedPrefix,
priority: 100,
minChars: 1,
maxChars: max(1, trimmedPrefix.count),
truncation: .preserveEnd
)
)

// Trailing whitespace is trimmed so the model continues from a clean word boundary instead of
// being asked to emit a leading-space token (which base models do poorly). A prefix ending
// mid-word keeps its final partial word, so mid-word continuation still works. Output spacing
// is reconciled downstream by `SuggestionTextNormalizer`.
let trimmedPrefix = Self.trimmingTrailingWhitespace(prefixText)
let kept = PromptSectionBudget.allocate(sections, totalChars: contextBudget)
let prefix = kept.first { $0.name == "prefix" }?.content ?? trimmedPrefix
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Prefix trimming may strip a leading space after truncation

When the total budget is smaller than trimmedPrefix.count, the allocator calls truncate(trimmedPrefix, toChars: cap, mode: .preserveEnd) and then trimmingCharacters(in: .whitespacesAndNewlines) on the result. A suffix of the prefix can start with a space or newline (e.g. "hello world".suffix(7) = " world" → trimmed to "world"), so the model receives a prompt that ends mid-word-boundary, defeating the invariant described in the doc comment. The original code always passed the full trimmedPrefix. This only fires when contextBudget is smaller than the prefix length (unlikely with the 2400-char default and pre-windowed input), but as a code path it silently breaks the caret-continuation contract.

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant