File size: 5,945 Bytes
7a1ad33 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | /**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Options for formatForSpeech().
*/
export interface FormatForSpeechOptions {
/**
* Maximum output length in characters before truncating.
* @default 500
*/
maxLength?: number;
/**
* Number of trailing path segments to keep when abbreviating absolute paths.
* @default 3
*/
pathDepth?: number;
/**
* Maximum number of characters in a JSON value before summarising it.
* @default 80
*/
jsonThreshold?: number;
}
// ANSI escape sequences (CSI, OSC, etc.)
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b(?:\[[0-9;]*[mGKHF]|\][^\x07\x1b]*\x07|[()][AB012])/g;
// Fenced code blocks ```lang\n...\n```
const CODE_FENCE_RE = /```[^\n]*\n([\s\S]*?)```/g;
// Inline code `...`
const INLINE_CODE_RE = /`([^`]+)`/g;
// Bold/italic markers **text**, *text*, __text__, _text_
// Exclude newlines so the pattern cannot span multiple lines and accidentally
// consume list markers that haven't been stripped yet.
const BOLD_ITALIC_RE = /\*{1,2}([^*\n]+)\*{1,2}|_{1,2}([^_\n]+)_{1,2}/g;
// Blockquote prefix "> "
const BLOCKQUOTE_RE = /^>\s?/gm;
// ATX headings # heading
const HEADING_RE = /^#{1,6}\s+/gm;
// Markdown links [text](url)
const LINK_RE = /\[([^\]]+)\]\([^)]+\)/g;
// Markdown list markers "- " or "* " or "N. " at line start
const LIST_MARKER_RE = /^[ \t]*(?:[-*]|\d+\.)\s+/gm;
// Two or more consecutive stack-trace frames (Node.js style " at …" lines).
// Matching blocks of ≥2 lets us replace each group in-place, preserving any
// text that follows the trace rather than appending it to the end.
const STACK_BLOCK_RE = /(?:^[ \t]+at [^\n]+(?:\n|$)){2,}/gm;
// Absolute Unix paths optionally ending with :line or :line:col
// Hyphen placed at start of char class to avoid useless-escape lint error
const UNIX_PATH_RE =
/(?:^|(?<=\s|[(`"']))(\/[-\w.@]+(?:\/[-\w.@]+)*)(:\d+(?::\d+)?)?/g;
// Absolute Windows paths C:\... or C:/... (any drive letter)
const WIN_PATH_RE =
/(?:^|(?<=\s|[(`"']))([A-Za-z]:[/\\][-\w. ]+(?:[/\\][-\w. ]+)*)(:\d+(?::\d+)?)?/g;
/**
* Abbreviates an absolute path to at most `depth` trailing segments,
* prefixed with "…". Optionally converts `:line` suffix to `line N`.
*/
function abbreviatePath(
full: string,
suffix: string | undefined,
depth: number,
): string {
const segments = full.split(/[/\\]/).filter(Boolean);
const kept = segments.length > depth ? segments.slice(-depth) : segments;
const abbreviated =
segments.length > depth ? `\u2026/${kept.join('/')}` : full;
if (!suffix) return abbreviated;
// Convert ":142" → " line 142", ":142:7" → " line 142"
const lineNum = suffix.split(':').filter(Boolean)[0];
return `${abbreviated} line ${lineNum}`;
}
/**
* Summarises a JSON string as "(JSON object with N keys)" or
* "(JSON array with N items)", falling back to the original if parsing fails.
*/
function summariseJson(jsonStr: string): string {
try {
const parsed: unknown = JSON.parse(jsonStr);
if (Array.isArray(parsed)) {
return `(JSON array with ${parsed.length} item${parsed.length === 1 ? '' : 's'})`;
}
if (parsed !== null && typeof parsed === 'object') {
const keys = Object.keys(parsed).length;
return `(JSON object with ${keys} key${keys === 1 ? '' : 's'})`;
}
} catch {
// not valid JSON — leave as-is
}
return jsonStr;
}
/**
* Transforms a markdown/ANSI-formatted string into speech-ready plain text.
*
* Transformations applied (in order):
* 1. Strip ANSI escape codes
* 2. Collapse fenced code blocks to their content (or a JSON summary)
* 3. Collapse stack traces to first frame + count
* 4. Strip markdown syntax (bold, italic, blockquotes, headings, links, lists, inline code)
* 5. Abbreviate deep absolute paths
* 6. Normalise whitespace
* 7. Truncate to maxLength
*/
export function formatForSpeech(
text: string,
options?: FormatForSpeechOptions,
): string {
const maxLength = options?.maxLength ?? 500;
const pathDepth = options?.pathDepth ?? 3;
const jsonThreshold = options?.jsonThreshold ?? 80;
if (!text) return '';
let out = text;
// 1. Strip ANSI escape codes
out = out.replace(ANSI_RE, '');
// 2. Fenced code blocks — try to summarise JSON content, else keep text
out = out.replace(CODE_FENCE_RE, (_match, body: string) => {
const trimmed = body.trim();
if (trimmed.length > jsonThreshold) {
const summary = summariseJson(trimmed);
if (summary !== trimmed) return summary;
}
return trimmed;
});
// 3. Collapse stack traces: replace each contiguous block of ≥2 frames
// in-place so that any text after the trace is preserved in order.
out = out.replace(STACK_BLOCK_RE, (block) => {
const lines = block
.trim()
.split('\n')
.map((l) => l.trim());
const rest = lines.length - 1;
return `${lines[0]} (and ${rest} more frame${rest === 1 ? '' : 's'})\n`;
});
// 4. Strip markdown syntax
out = out
.replace(INLINE_CODE_RE, '$1')
.replace(BOLD_ITALIC_RE, (_m, g1?: string, g2?: string) => g1 ?? g2 ?? '')
.replace(BLOCKQUOTE_RE, '')
.replace(HEADING_RE, '')
.replace(LINK_RE, '$1')
.replace(LIST_MARKER_RE, '');
// 5. Abbreviate absolute paths
// Windows paths first to avoid the leading letter being caught by Unix RE
out = out.replace(WIN_PATH_RE, (_m, full: string, suffix?: string) =>
abbreviatePath(full, suffix, pathDepth),
);
out = out.replace(UNIX_PATH_RE, (_m, full: string, suffix?: string) =>
abbreviatePath(full, suffix, pathDepth),
);
// 6. Normalise whitespace: collapse multiple blank lines, trim
out = out.replace(/\n{3,}/g, '\n\n').trim();
// 7. Truncate
if (out.length > maxLength) {
const total = out.length;
out = out.slice(0, maxLength).trimEnd() + `\u2026 (${total} chars total)`;
}
return out;
}
|