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;
}