From 6d24b597a044023eb5e7d31a4593a16a521046c4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 3 Apr 2026 14:07:04 -0400 Subject: [PATCH] feat(sdk): reduce context prompt sizes with truncation and cache-friendly ordering (#1615) * chore: add v1.31.0 npm known-issue notice to issue template config Adds a top-priority contact link to the issue template chooser so users are redirected to the Discussions announcement before opening a duplicate issue about v1.31.0 not being on npm. Co-Authored-By: Claude Sonnet 4.6 * feat(sdk): reduce context prompt sizes with truncation and cache-friendly ordering (#1614) - Reorder prompt assembly in PromptFactory to place stable content (role, workflow, phase instructions) before variable content (.planning/ files), enabling Anthropic prompt caching at 0.1x input cost on cache hits - Add markdown-aware truncation for oversized context files (headings + first paragraphs preserved, rest omitted with line counts) - Add ROADMAP.md milestone extraction to inject only the current milestone instead of the full roadmap - Export truncation utilities from SDK public API - 60 new + updated tests covering truncation, milestone extraction, cache-friendly ordering, and ContextEngine integration Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .github/ISSUE_TEMPLATE/config.yml | 3 + sdk/src/context-engine.test.ts | 84 +++++++++++ sdk/src/context-engine.ts | 52 ++++++- sdk/src/context-truncation.test.ts | 163 ++++++++++++++++++++ sdk/src/context-truncation.ts | 233 +++++++++++++++++++++++++++++ sdk/src/index.ts | 2 + sdk/src/phase-prompt.test.ts | 5 + sdk/src/phase-prompt.ts | 19 ++- 8 files changed, 554 insertions(+), 7 deletions(-) create mode 100644 sdk/src/context-truncation.test.ts create mode 100644 sdk/src/context-truncation.ts diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index f9afa32e2..464d4849b 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: "⚠️ v1.31.0 not on npm yet (known issue — workaround inside)" + url: https://github.com/gsd-build/get-shit-done/discussions + about: v1.31.0 was not published to npm due to a hardware failure. Read the pinned announcement for the workaround before opening an issue. - name: Discord Community url: https://discord.gg/gsd about: Ask questions and get help from the community diff --git a/sdk/src/context-engine.test.ts b/sdk/src/context-engine.test.ts index 5960ed4f1..0c8a921f9 100644 --- a/sdk/src/context-engine.test.ts +++ b/sdk/src/context-engine.test.ts @@ -184,6 +184,90 @@ describe('ContextEngine', () => { }); }); + describe('context truncation', () => { + it('truncates files exceeding maxContentLength', async () => { + const largeContent = Array.from({ length: 100 }, (_, i) => + `## Section ${i}\n\nFirst paragraph.\n\nLong detail ${'x'.repeat(200)}.` + ).join('\n\n'); + + await createPlanningDir(projectDir, { + 'STATE.md': '# State', + 'ROADMAP.md': '# Roadmap', + 'CONTEXT.md': largeContent, + }); + + const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 500 }); + const files = await engine.resolveContextFiles(PhaseType.Plan); + + // CONTEXT.md should be truncated + expect(files.context!.length).toBeLessThan(largeContent.length); + expect(files.context).toContain('[...'); + }); + + it('does not truncate files below threshold', async () => { + await createPlanningDir(projectDir, { + 'STATE.md': '# State\nproject: test', + 'ROADMAP.md': '# Roadmap\nphase 01', + 'CONTEXT.md': '# Context\nstack: node', + }); + + const engine = new ContextEngine(projectDir); + const files = await engine.resolveContextFiles(PhaseType.Plan); + + expect(files.context).toBe('# Context\nstack: node'); + }); + + it('never truncates STATE.md (not in truncatable list)', async () => { + const largeState = `# State\n\n${'x'.repeat(20000)}`; + await createPlanningDir(projectDir, { + 'STATE.md': largeState, + }); + + const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 100 }); + const files = await engine.resolveContextFiles(PhaseType.Execute); + + expect(files.state).toBe(largeState); + }); + + it('extracts current milestone from ROADMAP.md when state is available', async () => { + const roadmap = `# Roadmap + +## Milestone 1: Setup +### Phase 01 +Setup content. + +## Milestone 2: Build +### Phase 02 +Build content.`; + + await createPlanningDir(projectDir, { + 'STATE.md': 'Current Milestone: Build', + 'ROADMAP.md': roadmap, + 'CONTEXT.md': '# Context', + }); + + const engine = new ContextEngine(projectDir); + const files = await engine.resolveContextFiles(PhaseType.Plan); + + expect(files.roadmap).toContain('## Milestone 2: Build'); + expect(files.roadmap).not.toContain('### Phase 01'); + }); + + it('respects custom truncation options', async () => { + const content = '## Heading\n\nParagraph.\n\nMore.\n' + 'x'.repeat(500); + await createPlanningDir(projectDir, { + 'STATE.md': '# State', + 'ROADMAP.md': '# Roadmap', + 'CONTEXT.md': content, + }); + + // Low threshold forces truncation + const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 50 }); + const files = await engine.resolveContextFiles(PhaseType.Plan); + expect(files.context!.length).toBeLessThan(content.length); + }); + }); + describe('PHASE_FILE_MANIFEST', () => { it('covers all phase types', () => { for (const phase of Object.values(PhaseType)) { diff --git a/sdk/src/context-engine.ts b/sdk/src/context-engine.ts index 9643998c7..36a1f1dd2 100644 --- a/sdk/src/context-engine.ts +++ b/sdk/src/context-engine.ts @@ -5,6 +5,11 @@ * only needs STATE.md + config.json (minimal). Research needs STATE.md + * ROADMAP.md + CONTEXT.md. Plan needs all files. Verify needs STATE.md + * ROADMAP.md + REQUIREMENTS.md + PLAN/SUMMARY files. + * + * Context reduction (issue #1614): + * - Large files are truncated to keep prompts cache-friendly + * - ROADMAP.md is narrowed to the current milestone when possible + * - Truncation preserves headings + first paragraph per section */ import { readFile, access } from 'node:fs/promises'; @@ -14,6 +19,12 @@ import { constants } from 'node:fs'; import type { ContextFiles } from './types.js'; import { PhaseType } from './types.js'; import type { GSDLogger } from './logger.js'; +import { + truncateMarkdown, + extractCurrentMilestone, + DEFAULT_TRUNCATION_OPTIONS, + type TruncationOptions, +} from './context-truncation.js'; // ─── File manifest per phase ───────────────────────────────────────────────── @@ -64,16 +75,21 @@ const PHASE_FILE_MANIFEST: Record = { export class ContextEngine { private readonly planningDir: string; private readonly logger?: GSDLogger; + private readonly truncation: TruncationOptions; - constructor(projectDir: string, logger?: GSDLogger) { + constructor(projectDir: string, logger?: GSDLogger, truncation?: Partial) { this.planningDir = join(projectDir, '.planning'); this.logger = logger; + this.truncation = { ...DEFAULT_TRUNCATION_OPTIONS, ...truncation }; } /** * Resolve context files appropriate for the given phase type. * Reads each file defined in the phase manifest, returning undefined * for missing optional files and warning for missing required files. + * + * Files exceeding the truncation threshold are reduced to headings + + * first paragraphs. ROADMAP.md is narrowed to the current milestone. */ async resolveContextFiles(phaseType: PhaseType): Promise { const manifest = PHASE_FILE_MANIFEST[phaseType]; @@ -94,6 +110,40 @@ export class ContextEngine { } } + // Apply context reduction: milestone extraction then truncation + if (result.roadmap && result.state) { + const before = result.roadmap.length; + result.roadmap = extractCurrentMilestone(result.roadmap, result.state); + if (result.roadmap.length < before) { + this.logger?.debug?.('ROADMAP.md narrowed to current milestone', { + before, + after: result.roadmap.length, + }); + } + } + + // Truncate oversized files (skip config.json — structured data, not markdown) + const truncatable: Array<{ key: keyof ContextFiles; filename: string }> = [ + { key: 'roadmap', filename: 'ROADMAP.md' }, + { key: 'context', filename: 'CONTEXT.md' }, + { key: 'research', filename: 'RESEARCH.md' }, + { key: 'requirements', filename: 'REQUIREMENTS.md' }, + { key: 'plan', filename: 'PLAN.md' }, + { key: 'summary', filename: 'SUMMARY.md' }, + ]; + + for (const { key, filename } of truncatable) { + const raw = result[key]; + if (raw && raw.length > this.truncation.maxContentLength) { + const before = raw.length; + result[key] = truncateMarkdown(raw, filename, this.truncation); + this.logger?.debug?.(`${filename} truncated`, { + before, + after: result[key]!.length, + }); + } + } + return result; } diff --git a/sdk/src/context-truncation.test.ts b/sdk/src/context-truncation.test.ts new file mode 100644 index 000000000..85b8544cb --- /dev/null +++ b/sdk/src/context-truncation.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest'; +import { + truncateMarkdown, + extractCurrentMilestone, + DEFAULT_TRUNCATION_OPTIONS, +} from './context-truncation.js'; + +// ─── truncateMarkdown ─────────────────────────────────────────────────────── + +describe('truncateMarkdown', () => { + it('returns content unchanged when below threshold', () => { + const content = '# Title\n\nShort content.'; + const result = truncateMarkdown(content, 'TEST.md'); + expect(result).toBe(content); + }); + + it('truncates content above threshold, keeping headings and first paragraphs', () => { + const sections = []; + for (let i = 0; i < 20; i++) { + sections.push(`## Section ${i}\n\nFirst paragraph of section ${i}.\n\nSecond paragraph with lots of detail.\nMore detail here.\nEven more detail.`); + } + const content = `# Title\n\n${sections.join('\n\n')}`; + const result = truncateMarkdown(content, 'BIG.md', { maxContentLength: 100 }); + + // Headings preserved + expect(result).toContain('# Title'); + expect(result).toContain('## Section 0'); + expect(result).toContain('## Section 19'); + + // First paragraphs preserved + expect(result).toContain('First paragraph of section 0.'); + expect(result).toContain('First paragraph of section 19.'); + + // Second paragraphs omitted + expect(result).not.toContain('Second paragraph'); + expect(result).not.toContain('More detail here.'); + + // Truncation markers present + expect(result).toContain('[...'); + expect(result).toContain('lines omitted]'); + expect(result).toContain('[Truncated: read .planning/BIG.md for full content]'); + }); + + it('preserves YAML frontmatter entirely', () => { + const content = `---\nphase: "01"\nstatus: active\n---\n\n# Title\n\nParagraph 1.\n\nParagraph 2.\n${'x'.repeat(10000)}`; + const result = truncateMarkdown(content, 'STATE.md', { maxContentLength: 100 }); + + expect(result).toContain('---\nphase: "01"\nstatus: active\n---'); + expect(result).toContain('# Title'); + expect(result).toContain('Paragraph 1.'); + }); + + it('is smaller than original when truncated', () => { + const longContent = Array.from({ length: 200 }, (_, i) => + `## Section ${i}\n\nFirst paragraph.\n\nLong detail paragraph ${'x'.repeat(100)}.` + ).join('\n\n'); + + const result = truncateMarkdown(longContent, 'HUGE.md', { maxContentLength: 100 }); + expect(result.length).toBeLessThan(longContent.length); + }); + + it('handles content with no headings', () => { + const content = `First line.\n\nSecond paragraph.\n\nThird paragraph.\n${'x'.repeat(10000)}`; + const result = truncateMarkdown(content, 'FLAT.md', { maxContentLength: 100 }); + + // Should still truncate — first paragraph kept + expect(result).toContain('First line.'); + expect(result.length).toBeLessThan(content.length); + }); + + it('default threshold is 8192 characters', () => { + expect(DEFAULT_TRUNCATION_OPTIONS.maxContentLength).toBe(8192); + }); +}); + +// ─── extractCurrentMilestone ──────────────────────────────────────────────── + +describe('extractCurrentMilestone', () => { + const makeRoadmap = () => `# Project Roadmap + +## Milestone 1: Foundation +### Phase 01: Setup +Requirements for setup. +### Phase 02: Core +Requirements for core. + +## Milestone 2: Features +### Phase 03: Auth +Requirements for auth. +### Phase 04: API +Requirements for API. + +## Milestone 3: Polish +### Phase 05: UI +Requirements for UI.`; + + it('returns full roadmap when no state provided', () => { + const roadmap = makeRoadmap(); + expect(extractCurrentMilestone(roadmap)).toBe(roadmap); + }); + + it('returns full roadmap when milestone not found in state', () => { + const roadmap = makeRoadmap(); + const state = '# State\nstatus: active'; + expect(extractCurrentMilestone(roadmap, state)).toBe(roadmap); + }); + + it('extracts current milestone section by name', () => { + const roadmap = makeRoadmap(); + const state = 'Current Milestone: Features'; + const result = extractCurrentMilestone(roadmap, state); + + expect(result).toContain('## Milestone 2: Features'); + expect(result).toContain('### Phase 03: Auth'); + expect(result).toContain('### Phase 04: API'); + + // Other milestones omitted + expect(result).not.toContain('### Phase 01: Setup'); + expect(result).not.toContain('### Phase 05: UI'); + expect(result).toContain('other milestone(s) omitted'); + }); + + it('matches milestone name case-insensitively', () => { + const roadmap = makeRoadmap(); + const state = 'current milestone: features'; + const result = extractCurrentMilestone(roadmap, state); + + expect(result).toContain('## Milestone 2: Features'); + expect(result).not.toContain('### Phase 01: Setup'); + }); + + it('matches milestone from "milestone:" field in state', () => { + const roadmap = makeRoadmap(); + const state = '# State\nmilestone: Foundation\nphase: 01'; + const result = extractCurrentMilestone(roadmap, state); + + expect(result).toContain('## Milestone 1: Foundation'); + expect(result).toContain('### Phase 01: Setup'); + expect(result).not.toContain('### Phase 03: Auth'); + }); + + it('matches milestone from Current Position block', () => { + const roadmap = makeRoadmap(); + const state = `# State + +## Current Position +milestone: Polish +phase: 05`; + const result = extractCurrentMilestone(roadmap, state); + + expect(result).toContain('## Milestone 3: Polish'); + expect(result).toContain('### Phase 05: UI'); + expect(result).not.toContain('### Phase 01: Setup'); + }); + + it('preserves roadmap title in output', () => { + const roadmap = makeRoadmap(); + const state = 'Current Milestone: Features'; + const result = extractCurrentMilestone(roadmap, state); + + expect(result).toContain('# Project Roadmap'); + }); +}); diff --git a/sdk/src/context-truncation.ts b/sdk/src/context-truncation.ts new file mode 100644 index 000000000..abd86ec29 --- /dev/null +++ b/sdk/src/context-truncation.ts @@ -0,0 +1,233 @@ +/** + * Context truncation — reduces large .planning/ files to cache-friendly sizes. + * + * Two strategies: + * 1. Markdown-aware truncation: keeps headings + first paragraph per section, + * replaces the rest with a pointer to the full file. + * 2. Milestone extraction: pulls only the current milestone from ROADMAP.md. + * + * All functions are pure — no I/O, no side effects. + */ + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface TruncationOptions { + /** Max content length in characters before truncation kicks in. Default: 8192 */ + maxContentLength: number; +} + +export const DEFAULT_TRUNCATION_OPTIONS: TruncationOptions = { + maxContentLength: 8192, +}; + +// ─── Markdown-aware truncation ────────────────────────────────────────────── + +/** + * Truncate markdown content while preserving structure. + * + * Strategy: keep YAML frontmatter, all headings, and the first paragraph under + * each heading. Collapse everything else with a line count summary. + * + * Returns the original content unchanged if below maxContentLength. + */ +export function truncateMarkdown( + content: string, + filename: string, + options: TruncationOptions = DEFAULT_TRUNCATION_OPTIONS, +): string { + if (content.length <= options.maxContentLength) return content; + + const lines = content.split('\n'); + const kept: string[] = []; + let inFrontmatter = false; + let frontmatterDone = false; + let currentSectionLines = 0; + let paragraphKept = false; + let omittedLines = 0; + let inParagraph = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Handle YAML frontmatter (preserve entirely) + if (i === 0 && line.trim() === '---') { + inFrontmatter = true; + kept.push(line); + continue; + } + if (inFrontmatter) { + kept.push(line); + if (line.trim() === '---') { + inFrontmatter = false; + frontmatterDone = true; + } + continue; + } + + // Heading — always keep, reset paragraph tracking + if (/^#{1,6}\s/.test(line)) { + if (omittedLines > 0) { + kept.push(`[... ${omittedLines} lines omitted]`); + omittedLines = 0; + } + kept.push(line); + currentSectionLines = 0; + paragraphKept = false; + inParagraph = false; + continue; + } + + // Empty line — paragraph boundary + if (line.trim() === '') { + if (inParagraph && !paragraphKept) { + // End of first paragraph — mark it kept + paragraphKept = true; + } + if (!paragraphKept || currentSectionLines === 0) { + kept.push(line); + } else { + omittedLines++; + } + inParagraph = false; + continue; + } + + // Content line + currentSectionLines++; + if (!paragraphKept) { + // Still in the first paragraph — keep it + kept.push(line); + inParagraph = true; + } else { + omittedLines++; + } + } + + if (omittedLines > 0) { + kept.push(`[... ${omittedLines} lines omitted]`); + } + + const totalOmitted = lines.length - kept.length; + if (totalOmitted > 0) { + kept.push(''); + kept.push(`[Truncated: read .planning/${filename} for full content]`); + } + + return kept.join('\n'); +} + +// ─── Milestone extraction ─────────────────────────────────────────────────── + +/** + * Extract the current milestone section from a ROADMAP.md. + * + * Parses STATE.md to find the current milestone name, then extracts only + * that milestone's section from the roadmap. Falls back to full content + * if the milestone can't be identified or found. + */ +export function extractCurrentMilestone( + roadmapContent: string, + stateContent?: string, +): string { + if (!stateContent) return roadmapContent; + + // Find current milestone from STATE.md + // Patterns: "Current Milestone: X", "milestone: X", "## Current Position" block + const milestonePatterns = [ + /current\s*milestone\s*:\s*(.+)/i, + /^milestone\s*:\s*(.+)/im, + /##\s*current\s*position[\s\S]*?milestone\s*:\s*(.+)/i, + ]; + + let milestoneName: string | undefined; + for (const pattern of milestonePatterns) { + const match = stateContent.match(pattern); + if (match) { + milestoneName = match[1].trim(); + break; + } + } + + if (!milestoneName) return roadmapContent; + + // Find the milestone section in roadmap + // Look for heading containing the milestone name + const lines = roadmapContent.split('\n'); + let sectionStart = -1; + let sectionEnd = lines.length; + let sectionHeadingLevel = 0; + + for (let i = 0; i < lines.length; i++) { + const headingMatch = lines[i].match(/^(#{1,6})\s+(.+)/); + if (!headingMatch) continue; + + const level = headingMatch[1].length; + const title = headingMatch[2]; + + if (sectionStart === -1) { + // Looking for the milestone heading + if (title.toLowerCase().includes(milestoneName.toLowerCase())) { + sectionStart = i; + sectionHeadingLevel = level; + } + } else { + // Found start — look for next heading at same or higher level + if (level <= sectionHeadingLevel) { + sectionEnd = i; + break; + } + } + } + + if (sectionStart === -1) return roadmapContent; + + // Extract preamble (everything before first milestone heading at the same level) + const preamble: string[] = []; + for (let i = 0; i < lines.length; i++) { + const headingMatch = lines[i].match(/^(#{1,6})\s/); + if (headingMatch && headingMatch[1].length === sectionHeadingLevel && i !== sectionStart) { + // Hit another milestone-level heading before our section + if (i < sectionStart) { + break; // preamble ends at first milestone heading + } + } + if (i < sectionStart) { + // Keep top-level title and intro + if (i === 0 || lines[i].match(/^#\s/) || !lines[i].match(/^#{1,6}\s/)) { + preamble.push(lines[i]); + } + } + } + + const milestoneSection = lines.slice(sectionStart, sectionEnd).join('\n'); + const otherMilestones = countOtherMilestones(lines, sectionHeadingLevel, sectionStart); + + const result = [ + ...preamble, + '', + milestoneSection, + ]; + + if (otherMilestones > 0) { + result.push(''); + result.push(`[${otherMilestones} other milestone(s) omitted — read .planning/ROADMAP.md for full roadmap]`); + } + + return result.join('\n').trim(); +} + +function countOtherMilestones( + lines: string[], + headingLevel: number, + excludeIndex: number, +): number { + let count = 0; + for (let i = 0; i < lines.length; i++) { + if (i === excludeIndex) continue; + const match = lines[i].match(/^(#{1,6})\s/); + if (match && match[1].length === headingLevel) { + count++; + } + } + return count; +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index a8eaa3894..d43dc5878 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -296,6 +296,8 @@ export { GSDEventStream } from './event-stream.js'; export type { EventStreamContext } from './event-stream.js'; export { ContextEngine, PHASE_FILE_MANIFEST } from './context-engine.js'; export type { FileSpec } from './context-engine.js'; +export { truncateMarkdown, extractCurrentMilestone, DEFAULT_TRUNCATION_OPTIONS } from './context-truncation.js'; +export type { TruncationOptions } from './context-truncation.js'; export { getToolsForPhase, PHASE_AGENT_MAP, PHASE_DEFAULT_TOOLS } from './tool-scoping.js'; export { PromptFactory, extractBlock, extractSteps, PHASE_WORKFLOW_MAP } from './phase-prompt.js'; export { GSDLogger } from './logger.js'; diff --git a/sdk/src/phase-prompt.test.ts b/sdk/src/phase-prompt.test.ts index bad436bf4..daf33a595 100644 --- a/sdk/src/phase-prompt.test.ts +++ b/sdk/src/phase-prompt.test.ts @@ -154,6 +154,11 @@ describe('PromptFactory', () => { expect(prompt).toContain('# State'); expect(prompt).toContain('# Roadmap'); expect(prompt).toContain('## Phase Instructions'); + + // Cache-friendly ordering (#1614): stable prefix before variable context + const phaseInstrIdx = prompt.indexOf('## Phase Instructions'); + const contextIdx = prompt.indexOf('## Context'); + expect(phaseInstrIdx).toBeLessThan(contextIdx); }); it('assembles plan prompt with all context files', async () => { diff --git a/sdk/src/phase-prompt.ts b/sdk/src/phase-prompt.ts index 274d32aa1..97f52e213 100644 --- a/sdk/src/phase-prompt.ts +++ b/sdk/src/phase-prompt.ts @@ -102,8 +102,13 @@ export class PromptFactory { return sanitizePrompt(buildExecutorPrompt(plan, agentDef)); } + // Prompt assembly order is cache-optimized (#1614): + // Stable prefix (deterministic per phase type) → cached by Anthropic at 0.1x cost + // Variable suffix (.planning/ files) → uncached, changes per project/run const sections: string[] = []; + // ── STABLE PREFIX (cacheable across runs for the same phase type) ── + // ── Agent role ── const agentDef = await this.loadAgentDef(phaseType); if (agentDef) { @@ -131,18 +136,20 @@ export class PromptFactory { } } + // ── Phase-specific instructions (hardcoded per phase type — stable) ── + const phaseInstructions = this.getPhaseInstructions(phaseType); + if (phaseInstructions) { + sections.push(`## Phase Instructions\n\n${phaseInstructions}`); + } + + // ── VARIABLE SUFFIX (project-specific, changes per run) ── + // ── Context files ── const contextSection = this.formatContextFiles(contextFiles); if (contextSection) { sections.push(contextSection); } - // ── Phase-specific instructions ── - const phaseInstructions = this.getPhaseInstructions(phaseType); - if (phaseInstructions) { - sections.push(`## Phase Instructions\n\n${phaseInstructions}`); - } - return sanitizePrompt(sections.join('\n\n')); }