| name | system-prompt-builder |
| description | System prompt assembly for blah.chat AI backend. Multi-layer prompt construction with priority ordering, parallel context loading, memory truncation, budget awareness. Use when working with "system prompt", "base prompt", "identity memories", "custom instructions", "knowledge bank", "budget state", prompt ordering, or message assembly in Convex generation actions. |
System Prompt Builder
Backend system for assembling multi-layer AI system prompts with order-dependent priority. Located in packages/backend/convex/lib/prompts/.
Priority Order (Critical)
Later messages = higher priority due to LLM recency bias.
Structure:
- Base prompt (foundation)
- Identity memories (10% context budget)
- Contextual memories (prefetched for non-tool models)
- Project context
- Knowledge bank prompt
- Budget awareness prompts
- Document mode prompt
- Conversation-level system prompt
- Custom instructions (HIGHEST - last)
From systemBuilder.ts:
systemMessages.push({
role: "system",
content: basePrompt,
});
systemMessages.push({
role: "system",
content: `## Identity & Preferences\n\n${memoryContentForTracking}`,
});
const userPreferencesContent = `<user_preferences priority="highest">
## User Personalization Settings
**IMPORTANT**: These take absolute priority over default behavior.
${sections.join("\n\n")}
</user_preferences>`;
systemMessages.push({
role: "system",
content: userPreferencesContent,
});
Parallel Context Loading
Load user, conversation, project data in parallel to minimize latency.
From systemBuilder.ts:
const [user, conversation] = (await Promise.all([
ctx.runQuery(internal.lib.helpers.getCurrentUser, {}),
ctx.runQuery(internal.lib.helpers.getConversation, {
id: args.conversationId,
}),
])) as [Doc<"users"> | null, Doc<"conversations"> | null];
Do NOT load sequentially - use Promise.all().
Base Prompt Structure
Base prompt adapts based on:
- Model capabilities (vision, thinking, extended-thinking)
- Function calling support
- Custom instructions presence
From base.ts:
export function getBasePrompt(options: BasePromptOptions): string {
const hasCustomTone =
customInstructions?.enabled &&
customInstructions?.baseStyleAndTone &&
customInstructions.baseStyleAndTone !== "default";
const capabilities = buildCapabilities(modelConfig, hasFunctionCalling);
const memorySection = buildMemorySection(
hasFunctionCalling,
prefetchedMemories,
memoryExtractionLevel,
);
return `<system>
<identity>
<name>blah.chat</name>
<description>A personal AI assistant for thoughtful conversations.</description>
...
</identity>
<context>
<model>${modelConfig.name}</model>
<knowledge_cutoff>${knowledgeCutoff}</knowledge_cutoff>
<current_date>${currentDate}</current_date>
</context>
<capabilities>
${capabilities}
</capabilities>
${memorySection}
<response_style>
${toneSection}
...
</response_style>
</system>`;
}
Conditional Tone Section
When user has custom tone, base prompt defers to their preferences:
function buildToneSection(hasCustomTone: boolean): string {
if (hasCustomTone) {
return ` <tone>
<!-- User has custom tone/style preferences -->
- Adapt to user's explicitly configured style and tone preferences
- User's custom instructions take absolute priority
</tone>`;
}
return ` <tone>
- Conversational, genuine, direct
- Adapt to user's style and energy
- Avoid corporate/HR-speak ("I'd be happy to help!")
...
</tone>`;
}
Identity Memories
Load top 20 identity memories, truncate to 10% context budget.
From systemBuilder.ts:
const identityMemories: Doc<"memories">[] = await ctx.runQuery(
internal.memories.search.getIdentityMemories,
{
userId: args.userId,
limit: 20,
},
);
if (identityMemories.length > 0) {
const maxMemoryTokens = Math.floor(
args.modelConfig.contextWindow * 0.1,
);
const truncated = truncateMemories(identityMemories, maxMemoryTokens);
memoryContentForTracking = formatMemoriesByCategory(truncated);
if (memoryContentForTracking) {
systemMessages.push({
role: "system",
content: `## Identity & Preferences\n\n${memoryContentForTracking}`,
});
}
}
Skip for incognito blank slate mode:
const isBlankSlate =
conversation?.isIncognito &&
conversation?.incognitoSettings?.applyCustomInstructions === false;
if (!isBlankSlate) {
}
Memory Truncation
Truncate memories by category priority to fit budget.
Priority order: relationship > preference > identity > project > context
From formatting.ts:
export function truncateMemories(
memories: Array<Doc<"memories">>,
maxTokens: number,
): Array<Doc<"memories">> {
const priorityOrder: MemoryCategory[] = [
"relationship",
"preference",
"identity",
"project",
"context",
];
const categorized = new Map<MemoryCategory, Array<Doc<"memories">>>();
for (const mem of memories) {
const cat = (mem.metadata?.category as MemoryCategory) || "context";
if (!categorized.has(cat)) categorized.set(cat, []);
categorized.get(cat)?.push(mem);
}
const result: Array<Doc<"memories">> = [];
let estimatedTokens = 0;
for (const category of priorityOrder) {
mems = categorized.(category) || [];
( mem mems) {
tokens = (mem.);
(estimatedTokens + tokens > maxTokens) ;
result.(mem);
estimatedTokens += tokens;
}
}
result;
}
Format by category:
export function formatMemoriesByCategory(
memories: Array<Doc<"memories">>,
): string {
const categorized: Record<MemoryCategory, string[]> = {
relationship: [],
preference: [],
identity: [],
project: [],
context: [],
};
for (const mem of memories) {
const cat = (mem.metadata?.category as MemoryCategory) || "context";
categorized[cat].push(mem.content);
}
const sections: string[] = [];
if (categorized.relationship.length) {
sections.push(
`### Relationships\n${categorized.relationship.map((m) => `- ${m}`).join("\n")}`,
);
}
return `## Relevant Memories\n\n${sections.join("\n\n")}`;
}
Custom Instructions Override
User custom instructions placed LAST for maximum priority.
From systemBuilder.ts:
if (customInstructions?.enabled && !isBlankSlate) {
const {
aboutUser,
responseStyle,
baseStyleAndTone,
nickname,
occupation,
moreAboutYou,
} = customInstructions;
const sections: string[] = [];
const identityParts: string[] = [];
if (nickname) identityParts.push(`Name: ${nickname}`);
if (occupation) identityParts.push(`Role: ${occupation}`);
if (identityParts.length > 0) {
sections.push(`### User Identity\n${identityParts.join("\n")}`);
}
if (aboutUser || moreAboutYou) {
const aboutSections: string[] = [];
if (aboutUser) aboutSections.push(aboutUser);
if (moreAboutYou) aboutSections.push(moreAboutYou);
sections.push(`### About the User\n${aboutSections.join("\n\n")}`);
}
if (responseStyle) {
sections.push(`### Response Style Instructions\n${responseStyle}`);
}
if (baseStyleAndTone && baseStyleAndTone !== ) {
: <, > = {
: ,
: ,
};
toneDirective = toneDescriptions[baseStyleAndTone];
(toneDirective) {
sections.();
}
}
(sections. > ) {
userPreferencesContent = ;
systemMessages.({
: ,
: userPreferencesContent,
});
}
}
Knowledge Bank Prompt
Inject KB instructions for models with function calling.
From systemBuilder.ts:
if (!isBlankSlate && args.hasFunctionCalling) {
try {
const hasKnowledge = (await (ctx.runQuery as any)(
internal.knowledgeBank.index.hasKnowledge,
{ userId: args.userId },
)) as boolean;
const kbPrompt = getKnowledgeBankSystemPrompt(hasKnowledge);
if (kbPrompt) {
systemMessages.push({
role: "system",
content: kbPrompt,
});
}
} catch (error) {
logger.error("Failed to check knowledge bank", {
tag: "KnowledgeBank",
userId: args.userId,
error: String(error),
});
}
}
Budget State Awareness
Inject budget warnings when context is getting full.
From systemBuilder.ts:
if (args.budgetState && isContextGettingFull(args.budgetState)) {
systemMessages.push({
role: "system",
content: formatStatus(args.budgetState),
});
}
if (args.budgetState && shouldSuggestAskUser(args.budgetState)) {
const { searchHistory } = args.budgetState;
const lowQualityCount = searchHistory.filter(
(h) => h.topScore < LOW_QUALITY_SCORE_THRESHOLD,
).length;
systemMessages.push({
role: "system",
content: `[Stuck Detection: ${lowQualityCount} low-quality searches. Use askForClarification tool to get user input instead of continuing to search.]`,
});
}
Document Mode (Canvas)
Special prompt for document editing mode.
From systemBuilder.ts:
if (conversation?.mode === "document") {
const { DOCUMENT_MODE_PROMPT } = await import("./index");
systemMessages.push({
role: "system",
content: DOCUMENT_MODE_PROMPT,
});
}
Key Files
packages/backend/convex/lib/prompts/systemBuilder.ts - Main assembly logic
packages/backend/convex/lib/prompts/base.ts - Base prompt generation
packages/backend/convex/lib/prompts/formatting.ts - Memory formatting/truncation
packages/backend/convex/lib/budgetTracker.ts - Budget state helpers
packages/backend/convex/knowledgeBank/tool.ts - KB prompt generation
Anti-Patterns
DON'T load context sequentially - use Promise.all() for parallel loading.
DON'T place custom instructions early - they go LAST for highest priority.
DON'T skip incognito blank slate checks - respect user privacy settings.
DON'T exceed memory budget - always truncate to 10% context window.
DON'T forget graceful degradation - catch errors, continue without optional context.