Files
openclaw/extensions/sms/src/send.ts
snowzlmbot e8ad0466ff fix: render assistant transcript headers safely (#99404)
* fix: render assistant transcript headers safely

Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>

* fix(markdown): nest crossing annotation spans

* fix(markdown): protect final transport projections

* refactor(markdown): split transcript render ownership

* test(ui): cover assistant transcript render flag

* refactor(markdown): keep transcript helpers private

* docs(changelog): note assistant transcript headers

* chore(plugin-sdk): refresh transcript annotation baseline

* fix(markdown): harden final transcript projections

* docs(changelog): defer transcript note to release

* refactor(markdown): centralize HTML tokenization

* fix(markdown): satisfy lint gates

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
2026-07-14 02:25:37 -07:00

66 lines
2.1 KiB
TypeScript

// Sms plugin module implements send behavior.
import {
type MarkdownIR,
renderMarkdownIRChunksWithinLimit,
sanitizeAssistantVisibleText,
stripMarkdown,
} from "openclaw/plugin-sdk/text-chunking";
import { sendSmsViaTwilio } from "./twilio.js";
import type { ResolvedSmsAccount, SmsSendResult } from "./types.js";
const SMS_ASSISTANT_TRANSCRIPT_ROLE_PREFIX = "[assistant-authored transcript] ";
export function toSmsPlainText(text: string): string {
const visibleText = sanitizeAssistantVisibleText(text);
return stripMarkdown(visibleText, {
assistantTranscriptRoleHeaders: true,
assistantTranscriptRolePrefix: SMS_ASSISTANT_TRANSCRIPT_ROLE_PREFIX,
linkStyle: "label-and-url",
})
.replace(/\r\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function chunkSmsPlainText(text: string, limit: number): string[] {
const ir: MarkdownIR = { text, styles: [], links: [] };
return renderMarkdownIRChunksWithinLimit({
ir,
limit,
assistantTranscriptRoleMessageBoundaries: true,
// A soft split can promote mid-line prose to a new SMS boundary. Re-run
// the semantic annotation while measuring so the marker stays in-budget.
renderChunk: (chunk) =>
chunk.annotations?.some((annotation) => annotation.type === "assistant_transcript_role")
? `${SMS_ASSISTANT_TRANSCRIPT_ROLE_PREFIX}${chunk.text}`
: chunk.text,
measureRendered: (rendered) => rendered.length,
})
.map(({ rendered }) => rendered)
.filter(Boolean);
}
export async function sendSmsTextChunks(params: {
account: ResolvedSmsAccount;
to: string;
text: string;
}): Promise<SmsSendResult[]> {
const text = toSmsPlainText(params.text);
if (!text) {
throw new Error("SMS send requires non-empty text.");
}
const chunks = chunkSmsPlainText(text, params.account.textChunkLimit);
const sendChunks = chunks.length ? chunks : [text];
const results: SmsSendResult[] = [];
for (const textLocal of sendChunks) {
results.push(
await sendSmsViaTwilio({
account: params.account,
to: params.to,
text: textLocal,
}),
);
}
return results;
}