import { buildHardLimitsSection, DEFAULT_PROMPT_TEXT_GUIDANCE, DEFAULT_PROMPT_VOICE_GUIDANCE, getPromptBotName, getPromptCapabilityHonestyLine, getPromptImpossibleActionLine, getPromptMemoryDisabledLine, getPromptMemoryEnabledLine, getPromptSkipLine, getPromptStyle, getPromptTextGuidance, getPromptVoiceGuidance, buildVoiceToneGuardrails } from "./promptCore.ts"; import { buildTextCapabilitiesDocs, buildVoiceCapabilitiesDocs, type TextSystemCapabilityFlags, type VoiceSystemCapabilityFlags } from "./promptCapabilities.ts"; import { getMemorySettings, getVoiceSettings, getAutomationsSettings, getDiscoverySettings, getVideoContextSettings, getVoiceStreamWatchSettings, isBrowserEnabled, isDevTaskEnabled, isMinecraftEnabled, isResearchEnabled } from "../settings/agentStack.ts"; import { extractUrlsFromText } from "../bot/botHelpers.ts";
const IMAGE_URL_RE = /.(?:jpe?g|png|gif|webp|bmp|heic)(?:$|[?#])/i;
export function formatBehaviorMemoryFacts(facts, maxItems = 8) {
const rows = Array.isArray(facts) ? facts : [];
if (!rows.length) return "(no behavioral memory)";
return rows
.slice(0, Math.max(1, Number(maxItems) || 8))
.map((row) => {
const factType = String(row?.factType || row?.fact_type || "guidance").trim().toLowerCase() || "guidance";
const subjectLabel = String(row?.subjectLabel || row?.subject_label || row?.subject || "unknown").trim() || "unknown";
const factText = String(row?.fact || row?.noteText || row?.note_text || "")
.replace(/\s+/g, " ")
.trim();
if (!factText) return "";
return - [${factType}] ${subjectLabel}: ${factText};
})
.filter(Boolean)
.join("
");
}
export function buildSystemPrompt(settings) { const memoryEnabled = Boolean(getMemorySettings(settings).enabled); const textGuidance = getPromptTextGuidance(settings, DEFAULT_PROMPT_TEXT_GUIDANCE); const discovery = getDiscoverySettings(settings);
const maxMediaPromptChars = Math.max(100, Math.floor(Number(discovery.maxMediaPromptChars) || 900));
const capabilityFlags: TextSystemCapabilityFlags = { voiceEnabled: Boolean(getVoiceSettings(settings).enabled), webSearchEnabled: isResearchEnabled(settings), browserEnabled: isBrowserEnabled(settings), memoryEnabled, codeAgentEnabled: isDevTaskEnabled(settings), minecraftEnabled: isMinecraftEnabled(settings), mediaGenerationEnabled: Boolean(discovery.allowReplyImages || discovery.allowReplyVideos), gifsEnabled: Boolean(discovery.allowReplyGifs), automationEnabled: Boolean(getAutomationsSettings(settings).enabled), screenShareEnabled: Boolean(getVoiceStreamWatchSettings(settings).enabled), videoContextEnabled: Boolean(getVideoContextSettings(settings).enabled), maxMediaPromptChars };
return [
=== PERSONA ===,
You are ${getPromptBotName(settings)}, a real-feeling regular in a Discord server.,
Style: ${getPromptStyle(settings)}.,
...textGuidance,
=== CAPABILITIES ===,
getPromptCapabilityHonestyLine(settings),
memoryEnabled
? getPromptMemoryEnabledLine(settings)
: getPromptMemoryDisabledLine(settings),
getPromptImpossibleActionLine(settings),
=== TOOLS ===,
"If something you can do is currently disabled or budget-blocked, say it is currently unavailable with the reason. Do not claim a supported feature can never work.",
...buildTextCapabilitiesDocs(settings, capabilityFlags),
=== LIMITS ===,
Discord messages cap at ~1800 characters. Keep replies under that when possible; if you genuinely need more space your message will be automatically split across multiple posts.,
...buildHardLimitsSection(settings),
=== OUTPUT ===,
getPromptSkipLine(settings)
].join("
");
}
export function buildVoiceSystemPrompt(settings) { const memoryEnabled = Boolean(getMemorySettings(settings).enabled); const voiceGuidance = getPromptVoiceGuidance(settings, DEFAULT_PROMPT_VOICE_GUIDANCE);
const capabilityFlags: VoiceSystemCapabilityFlags = { webSearchEnabled: isResearchEnabled(settings), browserEnabled: isBrowserEnabled(settings), memoryEnabled, minecraftEnabled: isMinecraftEnabled(settings), screenShareEnabled: Boolean(getVoiceStreamWatchSettings(settings).enabled) };
return [
=== PERSONA ===,
You are ${getPromptBotName(settings)}, a real-feeling regular in a Discord server speaking in live voice chat.,
Style: ${getPromptStyle(settings)}.,
...voiceGuidance,
...buildVoiceToneGuardrails(),
=== CAPABILITIES ===,
getPromptCapabilityHonestyLine(settings),
memoryEnabled
? getPromptMemoryEnabledLine(settings)
: getPromptMemoryDisabledLine(settings),
getPromptImpossibleActionLine(settings),
=== TOOLS ===,
"If something you can do is currently disabled or budget-blocked, say it is currently unavailable with the reason. Do not claim a supported feature can never work.",
...buildVoiceCapabilitiesDocs(capabilityFlags),
=== LIMITS ===,
Voice replies should feel like live conversation. A short acknowledgement is often enough; go longer only when you genuinely have more to add.,
...buildHardLimitsSection(settings),
=== OUTPUT ===,
getPromptSkipLine(settings)
].join("
");
}
function stripEmojiForPrompt(text) { let value = String(text || ""); value = value.replace(/<a?:[a-zA-Z0-9_~]+:\d+>/g, ""); value = value.replace(/:[a-zA-Z0-9_+-]+:/g, ""); value = value.replace(/[\p{Extended_Pictographic}\uFE0F]/gu, ""); return value.replace(/\s+/g, " ").trim(); }
export function formatRecentChat(messages, options = {}) { if (!messages?.length) return "(no recent messages available)";
const imageCandidates = options && typeof options === "object" && !Array.isArray(options) ? (options as { imageCandidates?: unknown }).imageCandidates : []; const imageRefMap = buildHistoryImageReferenceMap(imageCandidates);
return messages
.slice()
.reverse()
.map((msg) => {
const isBot = msg.is_bot === 1 || msg.is_bot === true || msg.is_bot === "1";
const rawText = String(msg.content || "");
const normalized = isBot ? stripEmojiForPrompt(rawText) : rawText;
const text = replaceHistoryImageUrlsWithRefs(normalized, imageRefMap).replace(/\s+/g, " ").trim();
return - ${msg.author_name}: ${text || "(empty)"};
})
.join("
");
}
function buildHistoryImageReferenceMap(candidates) { const rows = Array.isArray(candidates) ? candidates : []; const map = new Map(); for (const row of rows) { const url = String(row?.url || "").trim(); const imageRef = String(row?.imageRef || "").trim(); if (!url || !imageRef) continue; map.set(url, { imageRef, authorName: String(row?.authorName || "unknown").trim() || "unknown", when: formatRelativePromptAge(row?.createdAt) }); } return map; }
function replaceHistoryImageUrlsWithRefs(text, imageRefMap) { const source = String(text || ""); if (!source || !(imageRefMap instanceof Map) || imageRefMap.size === 0) return source;
let replaced = source;
for (const rawUrl of extractUrlsFromText(source)) {
const url = String(rawUrl || "").trim();
if (!url || !isLikelyPromptImageUrl(url)) continue;
const ref = imageRefMap.get(url);
if (!ref) continue;
const whenLabel = ref.when ? , ${ref.when} : "";
const replacement = [${ref.imageRef} by ${ref.authorName}${whenLabel}];
replaced = replaced.split(url).join(replacement);
}
return replaced; }
function isLikelyPromptImageUrl(rawUrl) { const text = String(rawUrl || "").trim(); if (!text) return false; return IMAGE_URL_RE.test(text); }
function formatRelativePromptAge(createdAt) {
const createdAtMs = Date.parse(String(createdAt || ""));
if (!Number.isFinite(createdAtMs)) return "";
const deltaMinutes = Math.max(0, Math.round((Date.now() - createdAtMs) / 60000));
if (deltaMinutes < 1) return "just now";
if (deltaMinutes < 60) return ${deltaMinutes}m ago;
const deltaHours = Math.round(deltaMinutes / 60);
if (deltaHours < 24) return ${deltaHours}h ago;
const deltaDays = Math.round(deltaHours / 24);
return ${deltaDays}d ago;
}
function formatConversationWindowAge(ageMinutes) {
const normalizedAge = Number(ageMinutes);
if (!Number.isFinite(normalizedAge)) return "recent";
if (normalizedAge < 60) return ${Math.max(0, Math.round(normalizedAge))}m ago;
if (normalizedAge < 24 * 60) return ${Math.max(1, Math.round(normalizedAge / 60))}h ago;
return ${Math.max(1, Math.round(normalizedAge / (24 * 60)))}d ago;
}
export function formatConversationWindows(windows) { const rows = Array.isArray(windows) ? windows : []; if (!rows.length) return "(no matching conversation history)";
return rows
.slice(0, 4)
.map((window, index) => {
const ageLabel = formatConversationWindowAge(window?.ageMinutes);
const messages = Array.isArray(window?.messages) ? window.messages : [];
const isVoiceWindow = messages.some(
(msg) => String(msg?.message_id || "").startsWith("voice-")
);
const sourceLabel = isVoiceWindow ? "voice chat" : "text";
const lines = messages
.slice(0, 5)
.map((message) => {
const authorName = String(message?.author_name || message?.authorName || "unknown").trim() || "unknown";
const rawText = String(message?.content || "").trim();
const normalizedText =
message?.is_bot === 1 || message?.is_bot === true ? stripEmojiForPrompt(rawText) : rawText;
const text = normalizedText.replace(/\s+/g, " ").trim() || "(empty)";
const msgAge = formatRelativePromptAge(message?.created_at || message?.createdAt);
const msgAgeLabel = msgAge ? (${msgAge}) : "";
return - ${authorName}${msgAgeLabel}: ${text};
})
.join("
");
return - [C${index + 1}] ${ageLabel}, ${sourceLabel} ${lines};
})
.join("
");
}
export function formatConversationParticipantMemory({
participantProfiles = [],
selfFacts = [],
loreFacts = []
}: {
participantProfiles?: Array<Record<string, unknown>>;
selfFacts?: Array<Record<string, unknown>>;
loreFacts?: Array<Record<string, unknown>>;
}) {
const participants = Array.isArray(participantProfiles) ? participantProfiles : [];
const lines = participants
.slice(0, 8)
.map((participant) => {
const displayName = String(participant?.displayName || participant?.userId || "unknown").trim() || "unknown";
const facts = Array.isArray(participant?.facts) ? participant.facts : [];
if (!facts.length) return "";
const factLines = formatMemoryFacts(facts, {
includeType: false,
includeProvenance: false,
maxItems: participant?.isPrimary ? 8 : 3
})
.split("
")
.map((line) => ${line});
const roleLabel = participant?.isPrimary ? " (current speaker)" : "";
return [${displayName}${roleLabel}:, ...factLines].join("
");
})
.filter(Boolean);
if (Array.isArray(selfFacts) && selfFacts.length > 0) {
lines.push(
[
"Bot self:",
...formatMemoryFacts(selfFacts, {
includeType: false,
includeProvenance: false,
maxItems: 6
})
.split("
")
.map((line) => ${line})
].join("
")
);
}
if (Array.isArray(loreFacts) && loreFacts.length > 0) {
lines.push(
[
"Shared lore:",
...formatMemoryFacts(loreFacts, {
includeType: false,
includeProvenance: false,
maxItems: 6
})
.split("
")
.map((line) => ${line})
].join("
")
);
}
return lines.length ? lines.join(" ") : "(no participant memory)"; }
export function formatEmojiChoices(emojiOptions) {
if (!emojiOptions?.length) return "(no emoji options available)";
return emojiOptions.map((emoji) => - ${emoji}).join("
");
}
export function formatWebSearchFindings(webSearch) { if (!webSearch?.results?.length) return "(no web results available)";
return webSearch.results
.map((item, index) => {
const sourceId = String(index + 1);
const title = String(item.title || "untitled").trim();
const url = String(item.url || "").trim();
const domain = String(item.domain || "").trim();
const snippet = String(item.snippet || "").trim();
const pageSummary = String(item.pageSummary || "").trim();
const pageLine = pageSummary ? | page: ${pageSummary} : "";
const snippetLine = snippet ? | snippet: ${snippet} : "";
const domainLabel = domain ? (${domain}) : "";
return - [${sourceId}] ${title}${domainLabel} -> ${url}${snippetLine}${pageLine};
})
.join("
");
}
function formatPromptRelativeAge(rawValue) { return formatRelativePromptAge(rawValue) || "unknown"; }
type InitiativePromptMessage = { message_id?: string; author_name?: string; authorName?: string; content?: string; };
type InitiativePromptChannel = { channelId?: string; channelName?: string; name?: string; lastHumanAt?: string | null; lastHumanMessageId?: string | null; lastHumanAuthorName?: string | null; lastHumanSnippet?: string | null; lastBotAt?: string | null; recentHumanMessageCount?: number; recentMessages?: InitiativePromptMessage[]; };
function getInitiativeHistorySourceTag(messageId: unknown) { const normalizedMessageId = String(messageId || "").trim(); if (normalizedMessageId.startsWith("voice-") || normalizedMessageId.startsWith("voice-assistant-")) { return "vc"; } return "text"; }
export function formatInitiativeChannelSummaries(channels) { const rows = (Array.isArray(channels) ? channels : []) as InitiativePromptChannel[]; if (!rows.length) return "Eligible channels: (no eligible channels)";
const summaries = rows
.map((channel) => {
const channelName = String(channel?.channelName || channel?.name || "channel").trim() || "channel";
const lastHumanSnippet = String(channel?.lastHumanSnippet || "").trim();
const lastHumanAt = String(channel?.lastHumanAt || "").trim();
const lastHumanSourceTag = getInitiativeHistorySourceTag(channel?.lastHumanMessageId);
const lastHumanSourceLabel = lastHumanSourceTag === "vc" ? " [vc transcript]" : "";
const lastHumanAuthorName = String(channel?.lastHumanAuthorName || "").trim();
const lastHumanWho = lastHumanAuthorName ? (user: ${lastHumanAuthorName}) : "";
const lastHumanLine = lastHumanSnippet && lastHumanAt
? Last human message: ${formatPromptRelativeAge(lastHumanAt)}${lastHumanSourceLabel} — "${lastHumanSnippet}"${lastHumanWho}
: "Last human message: quiet";
const lastBotAt = String(channel?.lastBotAt || "").trim();
const botLine = lastBotAt
? Your last message: ${formatPromptRelativeAge(lastBotAt)}
: "Your last message: never";
const recentActivity = Number(channel?.recentHumanMessageCount || 0);
const activityLine =
recentActivity > 0
? Recent activity: ${recentActivity} message${recentActivity === 1 ? "" : "s"} in the last hour
: "Recent activity: idle";
const recentMessages = Array.isArray(channel?.recentMessages)
? channel.recentMessages
: [];
const messageLines = recentMessages.length
? recentMessages
.slice(-5)
.map((message) => {
const sourceTag = getInitiativeHistorySourceTag(message?.message_id);
const author = String(message?.author_name || message?.authorName || "unknown").trim() || "unknown";
const text = stripEmojiForPrompt(String(message?.content || ""))
.replace(/\s+/g, " ")
.trim() || "(empty)";
const msgId = String(message?.message_id || "").trim();
const idLabel = msgId ? [id:${msgId}] : "";
return - [${sourceTag}] ${author}: ${text}${idLabel};
})
.join("
")
: " - (no recent messages captured)";
return [
#${channelName} (text),
channelId: ${String(channel?.channelId || "").trim() || "(missing)"},
${lastHumanLine},
${botLine},
${activityLine},
" Recent messages ([text]=typed in channel, [vc]=transcript from linked voice chat):",
messageLines
].join("
");
})
.join("
");
return `Eligible channels:
${summaries}`; }
export function formatInitiativeFeedCandidates(candidates) { const rows = Array.isArray(candidates) ? candidates : []; if (!rows.length) return "Nothing new in your feed right now.";
const formattedRows = rows
.slice(0, 8)
.map((item, index) => {
const title = String(item?.title || "untitled").trim() || "untitled";
const source = String(item?.sourceLabel || item?.source || "web").trim() || "web";
const publishedAt = String(item?.publishedAt || "").trim();
const ageLabel = publishedAt ? formatPromptRelativeAge(publishedAt) : "recent";
const excerpt = String(item?.excerpt || "").trim();
const excerptLine = excerpt ? Note: ${excerpt} : "";
const url = String(item?.url || "").trim();
return ${index + 1}. "${title}" Source: ${source} · ${ageLabel} Link: ${url}${excerptLine};
})
.join("
");
return `Things from your feed (share if any catch your eye):
${formattedRows}`; }
export function formatInitiativeSourcePerformance(sources) { const rows = Array.isArray(sources) ? sources : []; if (!rows.length) return "No source performance data yet.";
const formattedRows = rows
.map((entry) => {
const label = String(entry?.label || entry?.source || "source").trim() || "source";
const shared = Math.max(0, Number(entry?.sharedCount || 0));
const fetched = Math.max(0, Number(entry?.fetchedCount || 0));
const engagement = Math.max(0, Number(entry?.engagementCount || 0));
const lastUsedAt = String(entry?.lastUsedAt || "").trim();
const lastUsedLabel = lastUsedAt ? , last used ${formatPromptRelativeAge(lastUsedAt)} : "";
return - ${label} — ${shared}/${fetched} candidates shared in last 2 weeks, ${engagement} community engagement${lastUsedLabel};
})
.join("
");
return Your feed sources: ${formattedRows};
}
export function formatInitiativeInterestFacts(facts) { const rows = Array.isArray(facts) ? facts : []; if (!rows.length) return "You're still getting to know this community.";
return rows
.slice(0, 8)
.map((fact) => - ${String(fact || "").replace(/\s+/g, " ").trim()})
.filter(Boolean)
.join("
");
}
function renderPromptMemoryFact(row, { includeType = true, includeProvenance = true } = {}) { const fact = String(row?.fact || "").replace(/\s+/g, " ").trim(); if (!fact) return "";
const type = String(row?.fact_type || "").trim().toLowerCase();
const label = includeType && type && type !== "other" ? ${type}: : "";
if (!includeProvenance) return ${label}${fact};
const evidence = String(row?.evidence_text || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 90);
const source = String(row?.source_message_id || "").trim().slice(0, 28);
const createdAt = String(row?.created_at || "").trim().slice(0, 10);
const confidence = Number(row?.confidence);
const confidenceLabel = Number.isFinite(confidence) ? | conf:${confidence.toFixed(2)} : "";
const evidenceLabel = evidence ? | evidence: "${evidence}" : "";
const sourceLabel = source ? | source:${source} : "";
const dateLabel = createdAt ? | date:${createdAt} : "";
return ${label}${fact}${evidenceLabel}${sourceLabel}${dateLabel}${confidenceLabel};
}
export function formatMemoryFacts(facts, { includeType = true, includeProvenance = true, maxItems = 12 } = {}) { if (!facts?.length) return "(no durable memory hits)";
return facts
.slice(0, Math.max(1, Number(maxItems) || 12))
.map((row) => {
const rendered = renderPromptMemoryFact(row, { includeType, includeProvenance });
return rendered ? - ${rendered} : "";
})
.filter(Boolean)
.join("
");
}
export function formatImageLookupCandidates(candidates) {
if (!candidates?.length) return "(no recent image references found)";
return candidates
.slice(0, 12)
.map((row, index) => {
const filename = String(row?.filename || "(unnamed)").trim();
const author = String(row?.authorName || "unknown").trim();
const when = formatRelativePromptAge(row?.createdAt) || String(row?.createdAt || "").trim();
const context = String(row?.context || "").trim();
const ref = String(row?.imageRef || IMG ${index + 1}).trim();
const whenLabel = when ? , ${when} : "";
const contextLabel = context ? | context: ${context} : "";
return - [${ref}] ${filename} by ${author}${whenLabel}${contextLabel};
})
.join("
");
}
