promptloom 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * promptloom — The Prompt Compiler
3
+ *
4
+ * Implements Claude Code's prompt assembly pattern, generalized:
5
+ *
6
+ * 1. Multi-zone cache scoping (N blocks, not just 2)
7
+ * 2. Conditional section inclusion (when predicates)
8
+ * 3. Static/dynamic section caching
9
+ * 4. Tool schemas with embedded prompts and deferred loading
10
+ * 5. Stable tool ordering for cache hits
11
+ * 6. Token estimation and budget tracking
12
+ *
13
+ * Usage:
14
+ *
15
+ * const pc = new PromptCompiler()
16
+ *
17
+ * // Zone 1: no-cache header
18
+ * pc.zone(null)
19
+ * pc.static('attribution', 'x-model: claude')
20
+ *
21
+ * // Zone 2: globally cacheable
22
+ * pc.zone('global')
23
+ * pc.static('identity', 'You are a coding assistant.')
24
+ * pc.static('rules', () => loadRules())
25
+ *
26
+ * // Zone 3: session-specific
27
+ * pc.zone(null)
28
+ * pc.dynamic('git', async () => gitStatus())
29
+ * pc.static('opus_only', 'Use extended thinking.', {
30
+ * when: (ctx) => ctx.model?.includes('opus'),
31
+ * })
32
+ *
33
+ * // Tools
34
+ * pc.tool({ name: 'bash', prompt: '...', inputSchema: {...} })
35
+ * pc.tool({ name: 'rare_tool', prompt: '...', inputSchema: {...}, deferred: true })
36
+ *
37
+ * const result = await pc.compile({ model: 'claude-opus-4-6' })
38
+ */
39
+
40
+ import type {
41
+ CacheBlock,
42
+ CacheScope,
43
+ CompileContext,
44
+ CompileResult,
45
+ CompilerOptions,
46
+ ComputeFn,
47
+ Entry,
48
+ Section,
49
+ SectionOptions,
50
+ TokenEstimate,
51
+ ToolDef,
52
+ ZoneMarker,
53
+ } from './types.ts'
54
+ import { SectionCache, resolveSections } from './section.ts'
55
+ import { ToolCache, compileTools } from './tool.ts'
56
+ import { estimateTokens } from './tokens.ts'
57
+
58
+ export class PromptCompiler {
59
+ private entries: Entry[] = []
60
+ private tools: ToolDef[] = []
61
+ private sectionCache: SectionCache
62
+ private toolCache: ToolCache
63
+ private options: Required<CompilerOptions>
64
+
65
+ constructor(options: CompilerOptions = {}) {
66
+ this.options = {
67
+ defaultCacheScope: options.defaultCacheScope ?? 'org',
68
+ dynamicCacheScope: options.dynamicCacheScope ?? null,
69
+ bytesPerToken: options.bytesPerToken ?? 4,
70
+ enableGlobalCache: options.enableGlobalCache ?? false,
71
+ }
72
+ this.sectionCache = new SectionCache()
73
+ this.toolCache = new ToolCache()
74
+ }
75
+
76
+ // ─── Zone API ────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Start a new cache zone. All sections after this marker are compiled
80
+ * into a single CacheBlock with the specified scope.
81
+ *
82
+ * Generalizes Claude Code's `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` to support
83
+ * N zones instead of just 2.
84
+ *
85
+ * @example
86
+ * pc.zone(null) // no-cache zone (attribution, headers)
87
+ * pc.zone('global') // globally cacheable (identity, rules)
88
+ * pc.zone('org') // org-level cacheable
89
+ * pc.zone(null) // session-specific (dynamic context)
90
+ */
91
+ zone(scope: CacheScope): this {
92
+ this.entries.push({ __type: 'zone', scope })
93
+ return this
94
+ }
95
+
96
+ /**
97
+ * Insert a cache boundary. Shorthand for starting a new zone with
98
+ * `dynamicCacheScope` (default: null).
99
+ *
100
+ * Equivalent to Claude Code's `SYSTEM_PROMPT_DYNAMIC_BOUNDARY`.
101
+ * Only effective when `enableGlobalCache` is true (otherwise a no-op
102
+ * since there's no scope difference to split on).
103
+ *
104
+ * For explicit multi-zone control, use `zone()` instead.
105
+ */
106
+ boundary(): this {
107
+ if (this.options.enableGlobalCache) {
108
+ return this.zone(this.options.dynamicCacheScope)
109
+ }
110
+ // When global cache is disabled, boundary is a no-op
111
+ // (all sections use defaultCacheScope anyway)
112
+ return this
113
+ }
114
+
115
+ // ─── Section API ─────────────────────────────────────────────
116
+
117
+ /**
118
+ * Add a static section. Computed once and cached for the session.
119
+ *
120
+ * Equivalent to Claude Code's `systemPromptSection()`.
121
+ *
122
+ * @param options.when - Conditional predicate. Section is skipped when false.
123
+ */
124
+ static(name: string, content: string | ComputeFn, options?: SectionOptions): this {
125
+ const compute = typeof content === 'string' ? () => content : content
126
+ this.entries.push({ name, compute, cacheBreak: false, when: options?.when })
127
+ return this
128
+ }
129
+
130
+ /**
131
+ * Add a dynamic section. Recomputed every compile() call.
132
+ *
133
+ * Equivalent to Claude Code's `DANGEROUS_uncachedSystemPromptSection()`.
134
+ *
135
+ * @param options.when - Conditional predicate. Section is skipped when false.
136
+ */
137
+ dynamic(name: string, compute: ComputeFn, options?: SectionOptions): this {
138
+ this.entries.push({ name, compute, cacheBreak: true, when: options?.when })
139
+ return this
140
+ }
141
+
142
+ // ─── Tool API ────────────────────────────────────────────────
143
+
144
+ /**
145
+ * Register a tool with an embedded prompt.
146
+ *
147
+ * Tools with `deferred: true` are compiled separately and excluded
148
+ * from the main tool list. They can be discovered on demand.
149
+ *
150
+ * Tools with `order` fields are sorted for stable serialization
151
+ * (cache hit optimization).
152
+ */
153
+ tool(def: ToolDef): this {
154
+ this.tools.push(def)
155
+ return this
156
+ }
157
+
158
+ // ─── Compile ─────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Compile the prompt: resolve sections per zone, compile tools,
162
+ * separate deferred tools, and estimate tokens.
163
+ *
164
+ * @param context - Optional context for conditional section evaluation.
165
+ * Sections with `when` predicates are evaluated against this.
166
+ */
167
+ async compile(context?: CompileContext): Promise<CompileResult> {
168
+ // 1. Group entries into zones
169
+ const zoneGroups = this.groupIntoZones()
170
+
171
+ // 2. Resolve each zone's sections → CacheBlock[]
172
+ const blocks: CacheBlock[] = []
173
+ for (const group of zoneGroups) {
174
+ const resolved = await resolveSections(group.sections, this.sectionCache, context)
175
+ const text = resolved.filter((s): s is string => s !== null).join('\n\n')
176
+ if (text) {
177
+ blocks.push({ text, cacheScope: group.scope })
178
+ }
179
+ }
180
+
181
+ // 3. Separate inline vs deferred tools
182
+ const inlineToolDefs = this.tools.filter((t) => !t.deferred)
183
+ const deferredToolDefs = this.tools.filter((t) => t.deferred)
184
+
185
+ // 4. Compile tools (prompts resolved and cached, sorted by order)
186
+ const compiledTools = await compileTools(inlineToolDefs, this.toolCache)
187
+ const compiledDeferred = await compileTools(deferredToolDefs, this.toolCache)
188
+
189
+ // 5. Estimate tokens
190
+ const bpt = this.options.bytesPerToken
191
+ const systemPromptTokens = blocks.reduce(
192
+ (sum, b) => sum + estimateTokens(b.text, bpt),
193
+ 0,
194
+ )
195
+ const toolTokens = compiledTools.reduce(
196
+ (sum, t) => sum + this.estimateToolTokens(t),
197
+ 0,
198
+ )
199
+ const deferredToolTokens = compiledDeferred.reduce(
200
+ (sum, t) => sum + this.estimateToolTokens(t),
201
+ 0,
202
+ )
203
+ const tokens: TokenEstimate = {
204
+ systemPrompt: systemPromptTokens,
205
+ tools: toolTokens,
206
+ deferredTools: deferredToolTokens,
207
+ total: systemPromptTokens + toolTokens,
208
+ }
209
+
210
+ // 6. Build the plain text version
211
+ const text = blocks.map((b) => b.text).join('\n\n')
212
+
213
+ return {
214
+ blocks,
215
+ tools: compiledTools,
216
+ deferredTools: compiledDeferred,
217
+ tokens,
218
+ text,
219
+ }
220
+ }
221
+
222
+ // ─── Cache Management ────────────────────────────────────────
223
+
224
+ /** Clear all caches. Call on `/clear` or `/compact`. */
225
+ clearCache(): void {
226
+ this.sectionCache.clear()
227
+ this.toolCache.clear()
228
+ }
229
+
230
+ /** Clear only section cache (tools stay cached) */
231
+ clearSectionCache(): void {
232
+ this.sectionCache.clear()
233
+ }
234
+
235
+ /** Clear only tool cache (forces prompt re-resolution) */
236
+ clearToolCache(): void {
237
+ this.toolCache.clear()
238
+ }
239
+
240
+ // ─── Inspection ──────────────────────────────────────────────
241
+
242
+ /** Get the number of registered sections */
243
+ get sectionCount(): number {
244
+ return this.entries.filter((e): e is Section => !('__type' in e)).length
245
+ }
246
+
247
+ /** Get the number of registered tools (inline + deferred) */
248
+ get toolCount(): number {
249
+ return this.tools.length
250
+ }
251
+
252
+ /** List registered section names with their types */
253
+ listSections(): Array<{ name: string; type: 'static' | 'dynamic' | 'zone' }> {
254
+ return this.entries.map((e) => {
255
+ if ('__type' in e) {
256
+ return { name: `zone:${e.scope ?? 'none'}`, type: 'zone' as const }
257
+ }
258
+ return {
259
+ name: e.name,
260
+ type: e.cacheBreak ? 'dynamic' as const : 'static' as const,
261
+ }
262
+ })
263
+ }
264
+
265
+ /** List registered tool names */
266
+ listTools(): string[] {
267
+ return this.tools.map((t) => t.name)
268
+ }
269
+
270
+ // ─── Internal ────────────────────────────────────────────────
271
+
272
+ /**
273
+ * Group entries into zones.
274
+ *
275
+ * Entries before any zone marker belong to the "initial zone" whose
276
+ * scope is determined by `enableGlobalCache` and `defaultCacheScope`.
277
+ */
278
+ private groupIntoZones(): Array<{ scope: CacheScope; sections: Section[] }> {
279
+ const initialScope = this.options.enableGlobalCache
280
+ ? 'global'
281
+ : this.options.defaultCacheScope
282
+
283
+ const zones: Array<{ scope: CacheScope; sections: Section[] }> = []
284
+ let currentZone: { scope: CacheScope; sections: Section[] } = {
285
+ scope: initialScope,
286
+ sections: [],
287
+ }
288
+
289
+ for (const entry of this.entries) {
290
+ if ('__type' in entry) {
291
+ // Zone marker: finalize current zone and start a new one
292
+ if (currentZone.sections.length > 0) {
293
+ zones.push(currentZone)
294
+ }
295
+ currentZone = { scope: entry.scope, sections: [] }
296
+ } else {
297
+ currentZone.sections.push(entry)
298
+ }
299
+ }
300
+
301
+ // Don't forget the last zone
302
+ if (currentZone.sections.length > 0) {
303
+ zones.push(currentZone)
304
+ }
305
+
306
+ return zones
307
+ }
308
+
309
+ private estimateToolTokens(tool: { description: string; input_schema: Record<string, unknown> }): number {
310
+ return (
311
+ estimateTokens(tool.description, this.options.bytesPerToken) +
312
+ estimateTokens(JSON.stringify(tool.input_schema), 2) // schemas are dense
313
+ )
314
+ }
315
+ }
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * promptloom — Prompt Compiler
3
+ *
4
+ * Weave production-grade LLM prompts with cache boundaries,
5
+ * tool injection, and token budgeting.
6
+ *
7
+ * Reverse-engineered from Claude Code's 7-layer prompt architecture.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { PromptCompiler } from 'promptloom'
12
+ *
13
+ * const pc = new PromptCompiler()
14
+ *
15
+ * pc.zone(null) // no-cache header
16
+ * pc.static('header', 'x-model: claude')
17
+ *
18
+ * pc.zone('global') // globally cacheable
19
+ * pc.static('identity', 'You are a coding assistant.')
20
+ * pc.static('rules', 'Follow clean code principles.')
21
+ *
22
+ * pc.zone(null) // session-specific
23
+ * pc.dynamic('context', async () => `Branch: main`)
24
+ * pc.static('opus_only', 'Use extended thinking.', {
25
+ * when: (ctx) => ctx.model?.includes('opus'),
26
+ * })
27
+ *
28
+ * pc.tool({
29
+ * name: 'read_file',
30
+ * prompt: 'Read a file. Always use absolute paths.',
31
+ * inputSchema: { type: 'object', properties: { path: { type: 'string' } } },
32
+ * })
33
+ *
34
+ * pc.tool({
35
+ * name: 'web_search',
36
+ * prompt: 'Search the web.',
37
+ * inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
38
+ * deferred: true, // loaded on demand, not in system prompt
39
+ * })
40
+ *
41
+ * const result = await pc.compile({ model: 'claude-opus-4-6' })
42
+ * ```
43
+ */
44
+
45
+ // Core
46
+ export { PromptCompiler } from './compiler.ts'
47
+
48
+ // Section helpers
49
+ export { section, dynamicSection, SectionCache, resolveSections } from './section.ts'
50
+
51
+ // Cache boundary (low-level utility, kept for backward compat)
52
+ export { CACHE_BOUNDARY, splitAtBoundary } from './boundary.ts'
53
+
54
+ // Provider formatters
55
+ export { toAnthropic, toOpenAI, toBedrock, toAnthropicBlocks } from './providers.ts'
56
+
57
+ // Tool helpers
58
+ export { defineTool, ToolCache, compileTool, compileTools } from './tool.ts'
59
+
60
+ // Token utilities
61
+ export {
62
+ estimateTokens,
63
+ estimateTokensForFileType,
64
+ createBudgetTracker,
65
+ checkBudget,
66
+ parseTokenBudget,
67
+ } from './tokens.ts'
68
+
69
+ // Types
70
+ export type {
71
+ Section,
72
+ ComputeFn,
73
+ WhenPredicate,
74
+ CacheScope,
75
+ CacheBlock,
76
+ CompileContext,
77
+ SectionOptions,
78
+ ZoneMarker,
79
+ Entry,
80
+ ToolDef,
81
+ CompiledTool,
82
+ JsonSchema,
83
+ TokenEstimate,
84
+ TokenBudgetConfig,
85
+ CompilerOptions,
86
+ CompileResult,
87
+ ProviderFormat,
88
+ } from './types.ts'
89
+ export type { BudgetTracker, BudgetDecision } from './tokens.ts'
90
+ export type { SplitOptions } from './boundary.ts'
91
+ export type {
92
+ AnthropicCacheControl,
93
+ AnthropicTextBlock,
94
+ AnthropicTool,
95
+ OpenAITool,
96
+ BedrockSystemBlock,
97
+ BedrockTool,
98
+ } from './providers.ts'
@@ -0,0 +1,160 @@
1
+ /**
2
+ * promptloom — Multi-provider output formatting
3
+ *
4
+ * Claude Code supports multiple API providers:
5
+ * - Anthropic (1P): cache_control with scope on text blocks
6
+ * - AWS Bedrock: cache_control without scope
7
+ * - Google Vertex: cache_control without scope
8
+ * - OpenAI: single string system prompt, no caching API
9
+ *
10
+ * This module formats CompileResult for each provider.
11
+ */
12
+
13
+ import type { CacheBlock, CompiledTool, CompileResult } from './types.ts'
14
+
15
+ // ─── Anthropic ───────────────────────────────────────────────────
16
+
17
+ export interface AnthropicCacheControl {
18
+ type: 'ephemeral'
19
+ ttl?: '5m' | '1h'
20
+ }
21
+
22
+ export interface AnthropicTextBlock {
23
+ type: 'text'
24
+ text: string
25
+ cache_control?: AnthropicCacheControl
26
+ }
27
+
28
+ export interface AnthropicTool {
29
+ name: string
30
+ description: string
31
+ input_schema: Record<string, unknown>
32
+ cache_control?: AnthropicCacheControl
33
+ defer_loading?: true
34
+ }
35
+
36
+ /**
37
+ * Format compile result for Anthropic Messages API.
38
+ *
39
+ * Mirrors Claude Code's `buildSystemPromptBlocks()`.
40
+ * Blocks with a non-null cacheScope get `cache_control: { type: 'ephemeral' }`.
41
+ */
42
+ export function toAnthropic(result: CompileResult): {
43
+ system: AnthropicTextBlock[]
44
+ tools: AnthropicTool[]
45
+ } {
46
+ return {
47
+ system: result.blocks.map((block) => ({
48
+ type: 'text' as const,
49
+ text: block.text,
50
+ ...(block.cacheScope !== null
51
+ ? { cache_control: { type: 'ephemeral' as const } }
52
+ : {}),
53
+ })),
54
+ tools: [...result.tools, ...result.deferredTools].map((tool) => ({
55
+ name: tool.name,
56
+ description: tool.description,
57
+ input_schema: tool.input_schema,
58
+ ...(tool.cache_control ? { cache_control: tool.cache_control } : {}),
59
+ ...(tool.defer_loading ? { defer_loading: true as const } : {}),
60
+ })),
61
+ }
62
+ }
63
+
64
+ // ─── OpenAI ──────────────────────────────────────────────────────
65
+
66
+ export interface OpenAITool {
67
+ type: 'function'
68
+ function: {
69
+ name: string
70
+ description: string
71
+ parameters: Record<string, unknown>
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Format compile result for OpenAI Chat Completions API.
77
+ *
78
+ * OpenAI uses a single string for system prompt (no block-level caching).
79
+ * Tools are wrapped in the `{ type: 'function', function: {...} }` format.
80
+ */
81
+ export function toOpenAI(result: CompileResult): {
82
+ system: string
83
+ tools: OpenAITool[]
84
+ } {
85
+ return {
86
+ system: result.text,
87
+ tools: result.tools.map((tool) => ({
88
+ type: 'function' as const,
89
+ function: {
90
+ name: tool.name,
91
+ description: tool.description,
92
+ parameters: tool.input_schema,
93
+ },
94
+ })),
95
+ }
96
+ }
97
+
98
+ // ─── AWS Bedrock ─────────────────────────────────────────────────
99
+
100
+ export type BedrockSystemBlock =
101
+ | { text: string }
102
+ | { cachePoint: { type: 'default' } }
103
+
104
+ export interface BedrockTool {
105
+ toolSpec: {
106
+ name: string
107
+ description: string
108
+ inputSchema: { jsonSchema: Record<string, unknown> }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Format compile result for AWS Bedrock Converse API.
114
+ *
115
+ * Bedrock uses `cachePoint: { type: 'default' }` instead of Anthropic's
116
+ * `cache_control: { type: 'ephemeral' }`. Tools use `toolSpec` wrapper.
117
+ */
118
+ export function toBedrock(result: CompileResult): {
119
+ system: BedrockSystemBlock[]
120
+ toolConfig: { tools: BedrockTool[] }
121
+ } {
122
+ return {
123
+ system: result.blocks.flatMap((block): BedrockSystemBlock[] => [
124
+ { text: block.text },
125
+ ...(block.cacheScope !== null
126
+ ? [{ cachePoint: { type: 'default' as const } }]
127
+ : []),
128
+ ]),
129
+ toolConfig: {
130
+ tools: result.tools.map((tool) => ({
131
+ toolSpec: {
132
+ name: tool.name,
133
+ description: tool.description,
134
+ inputSchema: { jsonSchema: tool.input_schema },
135
+ },
136
+ })),
137
+ },
138
+ }
139
+ }
140
+
141
+ // ─── Convenience: toAnthropicBlocks (backward compat) ────────────
142
+
143
+ /**
144
+ * Convert cache blocks to Anthropic API text block format.
145
+ *
146
+ * @deprecated Use `toAnthropic(result).system` instead for full formatting.
147
+ * Kept for backward compatibility.
148
+ */
149
+ export function toAnthropicBlocks(
150
+ blocks: CacheBlock[],
151
+ enableCaching = true,
152
+ ): AnthropicTextBlock[] {
153
+ return blocks.map((block) => ({
154
+ type: 'text' as const,
155
+ text: block.text,
156
+ ...(enableCaching && block.cacheScope !== null
157
+ ? { cache_control: { type: 'ephemeral' as const } }
158
+ : {}),
159
+ }))
160
+ }
package/src/section.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * promptloom — Section management
3
+ *
4
+ * Implements the two-tier section system from Claude Code:
5
+ * - `section()`: cached within session, computed once
6
+ * - `dynamicSection()`: recomputed every compile() call (cacheBreak: true)
7
+ *
8
+ * Extended with conditional inclusion via `when` predicates,
9
+ * mirroring Claude Code's `feature()` and `process.env.USER_TYPE` gates.
10
+ */
11
+
12
+ import type { CompileContext, ComputeFn, Section, SectionOptions, WhenPredicate } from './types.ts'
13
+
14
+ /**
15
+ * Create a static section. Content is computed once and cached.
16
+ *
17
+ * Use for: identity prompts, rules, style guides — anything that
18
+ * doesn't change between turns.
19
+ */
20
+ export function section(name: string, compute: ComputeFn, options?: SectionOptions): Section {
21
+ return { name, compute, cacheBreak: false, when: options?.when }
22
+ }
23
+
24
+ /**
25
+ * Create a dynamic section. Content is recomputed every compile() call.
26
+ *
27
+ * Claude Code calls this `DANGEROUS_uncachedSystemPromptSection` —
28
+ * the naming reflects that dynamic sections break prompt cache stability.
29
+ *
30
+ * Use for: MCP server instructions, real-time status, anything that
31
+ * changes between turns.
32
+ */
33
+ export function dynamicSection(name: string, compute: ComputeFn, options?: SectionOptions): Section {
34
+ return { name, compute, cacheBreak: true, when: options?.when }
35
+ }
36
+
37
+ // ─── Section Cache ───────────────────────────────────────────────
38
+
39
+ /**
40
+ * In-memory section cache.
41
+ *
42
+ * Mirrors `STATE.systemPromptSectionCache` from Claude Code.
43
+ * Static sections are computed once per session and cached here.
44
+ * Dynamic sections (cacheBreak: true) bypass the cache entirely.
45
+ */
46
+ export class SectionCache {
47
+ private cache = new Map<string, string | null>()
48
+
49
+ get(name: string): string | null | undefined {
50
+ return this.cache.get(name)
51
+ }
52
+
53
+ has(name: string): boolean {
54
+ return this.cache.has(name)
55
+ }
56
+
57
+ set(name: string, value: string | null): void {
58
+ this.cache.set(name, value)
59
+ }
60
+
61
+ clear(): void {
62
+ this.cache.clear()
63
+ }
64
+
65
+ get size(): number {
66
+ return this.cache.size
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Resolve an array of sections, using cache for static ones.
72
+ *
73
+ * Mirrors `resolveSystemPromptSections()` from Claude Code:
74
+ * - Sections with a `when` predicate are filtered by compile context
75
+ * - Static sections: check cache first, compute if missing
76
+ * - Dynamic sections: always recompute
77
+ * - All sections resolved in parallel via Promise.all
78
+ */
79
+ export async function resolveSections(
80
+ sections: Section[],
81
+ cache: SectionCache,
82
+ context?: CompileContext,
83
+ ): Promise<(string | null)[]> {
84
+ // Filter by when predicate
85
+ const ctx = context ?? {}
86
+ const active = sections.filter((s) => !s.when || s.when(ctx))
87
+
88
+ return Promise.all(
89
+ active.map(async (s) => {
90
+ // Dynamic sections always recompute
91
+ if (!s.cacheBreak && cache.has(s.name)) {
92
+ return cache.get(s.name) ?? null
93
+ }
94
+
95
+ const value = await s.compute()
96
+ // Only cache static sections
97
+ if (!s.cacheBreak) {
98
+ cache.set(s.name, value)
99
+ }
100
+ return value
101
+ }),
102
+ )
103
+ }