import { stripHeartbeatToken } from "../heartbeat.js"; import { HEARTBEAT_TOKEN, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { ReplyPayload } from "../types.js"; import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js"; import { resolveResponsePrefixTemplate, type ResponsePrefixContext, } from "./response-prefix-template.js"; export type NormalizeReplyOptions = { responsePrefix?: string; /** Context for template variable interpolation in responsePrefix */ responsePrefixContext?: ResponsePrefixContext; onHeartbeatStrip?: () => void; stripHeartbeat?: boolean; silentToken?: string; }; export function normalizeReplyPayload( payload: ReplyPayload, opts: NormalizeReplyOptions = {}, ): ReplyPayload | null { const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0); const trimmed = payload.text?.trim() ?? ""; if (!trimmed && !hasMedia) return null; const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { if (!hasMedia) return null; text = ""; } if (text && !trimmed) { // Keep empty text when media exists so media-only replies still send. text = ""; } const shouldStripHeartbeat = opts.stripHeartbeat ?? true; if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) { const stripped = stripHeartbeatToken(text, { mode: "message" }); if (stripped.didStrip) opts.onHeartbeatStrip?.(); if (stripped.shouldSkip && !hasMedia) return null; text = stripped.text; } if (text) { text = sanitizeUserFacingText(text); } if (!text?.trim() && !hasMedia) return null; // Resolve template variables in responsePrefix if context is provided const effectivePrefix = opts.responsePrefixContext ? resolveResponsePrefixTemplate(opts.responsePrefix, opts.responsePrefixContext) : opts.responsePrefix; if ( effectivePrefix && text && text.trim() !== HEARTBEAT_TOKEN && !text.startsWith(effectivePrefix) ) { text = `${effectivePrefix} ${text}`; } return { ...payload, text }; }