switchroom 0.16.24 → 0.16.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +135 -37
- package/dist/host-control/main.js +13 -7
- package/package.json +2 -2
- package/telegram-plugin/answer-stream.ts +7 -6
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +255 -30
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/format.ts +335 -27
- package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- package/telegram-plugin/gateway/gateway.ts +169 -12
- package/telegram-plugin/gateway/ipc-server.ts +11 -6
- package/telegram-plugin/gateway/permission-timeout.ts +76 -0
- package/telegram-plugin/permission-title.ts +3 -0
- package/telegram-plugin/retry-api-call.ts +27 -0
- package/telegram-plugin/rich-send.ts +29 -0
- package/telegram-plugin/shared/bot-runtime.ts +6 -1
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/stream-reply-handler.ts +9 -1
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +43 -0
- package/telegram-plugin/tests/poll-health.test.ts +64 -0
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* - splitMarkdownChunks: split a long markdown body into <=maxLen chunks
|
|
17
17
|
* at safe boundaries (never mid code-fence, never mid table row),
|
|
18
18
|
* defaulting maxLen to the rich-message cap of 32768.
|
|
19
|
+
* - normalizeParagraphBreaks: promote a LONE prose `\n` into a GFM hard
|
|
20
|
+
* break (` \n`) so paragraph separation survives the rich GFM path,
|
|
21
|
+
* while leaving lists / tables / code / `\n\n` untouched.
|
|
19
22
|
* - RICH_MESSAGE_MAX_CHARS: the rich-text wire cap (32768).
|
|
20
23
|
*/
|
|
21
24
|
|
|
@@ -80,50 +83,355 @@ export function repairEscapedWhitespace(text: string): string {
|
|
|
80
83
|
// index and produce "undefined" in the Telegram output. A nonce that is unique
|
|
81
84
|
// per invocation makes the sentinel statistically impossible to collide with.
|
|
82
85
|
const nonce = Math.random().toString(36).slice(2)
|
|
83
|
-
const CODE_MASK_PH = `\x00RM${nonce}_`
|
|
84
86
|
const BACKSLASH_PH = `\x00BK${nonce}_`
|
|
85
87
|
|
|
86
|
-
// Mask fenced code blocks
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
// Mask fenced code blocks and inline code spans so the unescape pass never
|
|
89
|
+
// touches their content (shared masker — see maskCodeRegions for the exact
|
|
90
|
+
// closed-fence / inline-span definitions).
|
|
91
|
+
const { masked, restore } = maskCodeRegions(text, nonce)
|
|
92
|
+
|
|
93
|
+
// Order matters: protect existing `\\` first so `\\n` stays as a literal
|
|
94
|
+
// backslash + n and doesn't become a newline.
|
|
95
|
+
const unescaped = masked
|
|
96
|
+
.replace(/\\\\/g, BACKSLASH_PH)
|
|
97
|
+
.replace(/\\n/g, '\n')
|
|
98
|
+
.replace(/\\r/g, '\r')
|
|
99
|
+
.replace(/\\t/g, '\t')
|
|
100
|
+
.replace(/\\"/g, '"')
|
|
101
|
+
.replace(new RegExp(BACKSLASH_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '\\')
|
|
102
|
+
|
|
103
|
+
// Restore masked code spans verbatim.
|
|
104
|
+
return restore(unescaped)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Shared code-region masking (used by repairEscapedWhitespace AND
|
|
109
|
+
// normalizeParagraphBreaks). Closed fenced blocks (``` … ```) and inline
|
|
110
|
+
// code spans (` … `) are replaced with unique placeholders so neither pass
|
|
111
|
+
// ever rewrites their interior; `restore` puts them back verbatim.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
interface MaskedCode {
|
|
115
|
+
masked: string
|
|
116
|
+
restore: (s: string) => string
|
|
117
|
+
/** The placeholder prefix injected for each masked region (fence or span). */
|
|
118
|
+
placeholder: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Mask fenced code blocks and inline code spans with collision-resistant
|
|
123
|
+
* placeholders. `nonce` is a per-call random string the caller already holds
|
|
124
|
+
* (so two maskers in one function share one nonce namespace cleanly).
|
|
125
|
+
*
|
|
126
|
+
* Fenced blocks are extracted FIRST and only when CLOSED (matching ```), so an
|
|
127
|
+
* unclosed fence is left intact rather than misparsed by the inline pass. Inline
|
|
128
|
+
* spans use `[^\`\n]+` — the same definition the chunker treats as code.
|
|
129
|
+
*/
|
|
130
|
+
function maskCodeRegions(text: string, nonce: string): MaskedCode {
|
|
131
|
+
const CODE_MASK_PH = `\x00RM${nonce}_`
|
|
97
132
|
const codeMasks: string[] = []
|
|
98
133
|
|
|
99
134
|
const masked = text
|
|
100
|
-
// Closed fenced code blocks only (``` ... ``` with a matching closer).
|
|
101
135
|
.replace(/```[\s\S]*?```/g, (m) => {
|
|
102
136
|
const idx = codeMasks.length
|
|
103
137
|
codeMasks.push(m)
|
|
104
138
|
return `${CODE_MASK_PH}${idx}\x00`
|
|
105
139
|
})
|
|
106
|
-
// Inline code spans: at least one character between backticks, no embedded
|
|
107
|
-
// backtick or newline.
|
|
108
140
|
.replace(/`[^`\n]+`/g, (m) => {
|
|
109
141
|
const idx = codeMasks.length
|
|
110
142
|
codeMasks.push(m)
|
|
111
143
|
return `${CODE_MASK_PH}${idx}\x00`
|
|
112
144
|
})
|
|
113
145
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
.replace(
|
|
120
|
-
.replace(/\\t/g, '\t')
|
|
121
|
-
.replace(/\\"/g, '"')
|
|
122
|
-
.replace(new RegExp(BACKSLASH_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '\\')
|
|
146
|
+
const restoreRe = new RegExp(
|
|
147
|
+
`${CODE_MASK_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)\x00`,
|
|
148
|
+
'g',
|
|
149
|
+
)
|
|
150
|
+
const restore = (s: string): string =>
|
|
151
|
+
s.replace(restoreRe, (_m, idx) => codeMasks[Number(idx)] ?? _m)
|
|
123
152
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
153
|
+
return { masked, restore, placeholder: CODE_MASK_PH }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Paragraph-break normalizer — make lone prose newlines survive the GFM path
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* GFM (the rich-message render path) treats a LONE `\n` as a *soft* break: the
|
|
162
|
+
* two lines collapse onto the same visual line, so a model that separates its
|
|
163
|
+
* paragraphs with a single newline produces a cramped wall of text. The old
|
|
164
|
+
* markdown→HTML path rendered every `\n` as a hard break, which masked the
|
|
165
|
+
* habit; the rich path no longer does.
|
|
166
|
+
*
|
|
167
|
+
* This normalizer fixes that DETERMINISTICALLY without breaking GFM block
|
|
168
|
+
* syntax. It does exactly two things, on code-masked text:
|
|
169
|
+
*
|
|
170
|
+
* 1. Collapse runs of 3+ newlines down to exactly `\n\n` (never collapse a
|
|
171
|
+
* genuine `\n\n` paragraph gap).
|
|
172
|
+
* 2. Promote a LONE `\n` (one not adjacent to another `\n`) into a GFM hard
|
|
173
|
+
* break (` \n`, two trailing spaces) — but ONLY when it is a genuine
|
|
174
|
+
* prose paragraph break.
|
|
175
|
+
*
|
|
176
|
+
* The promotion heuristic is deliberately CONSERVATIVE — it prefers a false
|
|
177
|
+
* negative (leaving a break un-promoted, so two prose lines stay cramped) over
|
|
178
|
+
* a false positive (double-spacing a tight list or table). A break is promoted
|
|
179
|
+
* only when ALL of these hold:
|
|
180
|
+
*
|
|
181
|
+
* - The preceding line ends in sentence-terminal punctuation: `.`, `!`, `?`,
|
|
182
|
+
* `:`, or a closing `)` / `"` / `'` / `’` / `”` that itself follows such a
|
|
183
|
+
* terminator (e.g. `...done.")`).
|
|
184
|
+
* - The preceding line is NOT itself a marker line (list / table / quote /
|
|
185
|
+
* heading).
|
|
186
|
+
* - The NEXT line starts with a non-marker character: not a list bullet
|
|
187
|
+
* (`-`/`*`/`+`/`\d+.`/`\d+)`, incl. indented), not a table row (`|` or
|
|
188
|
+
* ` | `), not a blockquote (`>`), not a heading (`#`), not blank.
|
|
189
|
+
*
|
|
190
|
+
* Code fences and inline code are masked out before any of this runs, so their
|
|
191
|
+
* interior `\n`s are never touched.
|
|
192
|
+
*/
|
|
193
|
+
export function normalizeParagraphBreaks(text: string): string {
|
|
194
|
+
if (!text.includes('\n')) return text
|
|
195
|
+
|
|
196
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
197
|
+
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
198
|
+
|
|
199
|
+
// Step 1: collapse 3+ newlines to exactly two. This also normalizes runs that
|
|
200
|
+
// contain interleaved spaces only between the newlines is NOT done here —
|
|
201
|
+
// we only touch pure newline runs so we never eat meaningful whitespace.
|
|
202
|
+
let out = masked.replace(/\n{3,}/g, '\n\n')
|
|
203
|
+
|
|
204
|
+
// Step 2: walk lines and promote lone prose breaks. We rebuild the string by
|
|
205
|
+
// joining lines with the right separator. A separator is "hard" (` \n`) only
|
|
206
|
+
// when the break between this line and the next is a genuine prose paragraph
|
|
207
|
+
// break per the heuristic; otherwise it stays a plain `\n`. Blank lines (the
|
|
208
|
+
// `\n\n` gaps) are preserved as empty entries in the split, so we never
|
|
209
|
+
// promote a break that is adjacent to a blank line.
|
|
210
|
+
const lines = out.split('\n')
|
|
211
|
+
const pieces: string[] = []
|
|
212
|
+
for (let i = 0; i < lines.length; i++) {
|
|
213
|
+
let line = lines[i]
|
|
214
|
+
const isLast = i === lines.length - 1
|
|
215
|
+
const next = isLast ? '' : lines[i + 1]
|
|
216
|
+
// A blank current or next line means this is part of a `\n\n` gap — leave
|
|
217
|
+
// the separator as a plain newline (the blank entry reconstructs the gap).
|
|
218
|
+
const promote =
|
|
219
|
+
!isLast &&
|
|
220
|
+
line.trim() !== '' &&
|
|
221
|
+
next.trim() !== '' &&
|
|
222
|
+
shouldPromoteBreak(line, next, placeholder)
|
|
223
|
+
if (promote) {
|
|
224
|
+
// Strip any trailing whitespace the line already carried so we emit
|
|
225
|
+
// exactly one ` \n` hard break (never accumulate spaces on a re-run).
|
|
226
|
+
// Include `\r` so a CRLF source ("Alpha.\r\nBravo.") doesn't strand a
|
|
227
|
+
// lone carriage return before the injected ` \n`.
|
|
228
|
+
line = line.replace(/[ \t\r]+$/, '')
|
|
229
|
+
}
|
|
230
|
+
pieces.push(line)
|
|
231
|
+
if (isLast) break
|
|
232
|
+
pieces.push(promote ? ' \n' : '\n')
|
|
233
|
+
}
|
|
234
|
+
out = pieces.join('')
|
|
235
|
+
|
|
236
|
+
// Step 3: guarantee a blank line (`\n\n`) at BLOCK BOUNDARIES. The
|
|
237
|
+
// prose-promotion above keeps lists/tables tight by leaving their single
|
|
238
|
+
// `\n` separators alone — but GFM's rich renderer needs a blank line to
|
|
239
|
+
// START a new block, so a block that is glued to the previous line by a
|
|
240
|
+
// single `\n` fails to render (a table prints as literal pipe text, prose
|
|
241
|
+
// after a list is absorbed as a lazy list continuation). This pass inserts
|
|
242
|
+
// the missing blank line at those transitions only, on the same masked text,
|
|
243
|
+
// never touching code interiors, never collapsing/expanding existing `\n\n`,
|
|
244
|
+
// and never splitting a table's header/delimiter/body rows apart.
|
|
245
|
+
out = ensureBlockBoundaries(out, placeholder)
|
|
246
|
+
|
|
247
|
+
return restore(out)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Block-boundary blank-line guarantee (Step 3 of normalizeParagraphBreaks)
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/** A line that begins a GFM list item (bullet or ordered), incl. leading indent. */
|
|
255
|
+
function isListItemLine(line: string): boolean {
|
|
256
|
+
const t = line.trimStart()
|
|
257
|
+
return /^[-*+]\s/.test(t) || /^\d+[.)]\s/.test(t)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A GFM table body/header row: a line whose first non-space char is `|`. */
|
|
261
|
+
function isTableRowLine(line: string): boolean {
|
|
262
|
+
return /^\s*\|/.test(line)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A GFM table delimiter row: optional leading pipe, then one or more
|
|
267
|
+
* `:?-{1,}:?` cells separated by pipes (e.g. `|---|---|`, `---|:--:`,
|
|
268
|
+
* `| :-- | --: |`). This is what turns the line ABOVE it into a table header.
|
|
269
|
+
*/
|
|
270
|
+
function isTableDelimiterLine(line: string): boolean {
|
|
271
|
+
return /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(line)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A fenced-code OPEN line — either a literal ``` fence or a masked block. */
|
|
275
|
+
function isFenceOpenLine(line: string, placeholder?: string): boolean {
|
|
276
|
+
const t = line.trimStart()
|
|
277
|
+
if (placeholder != null && placeholder.length > 0 && t.startsWith(placeholder)) return true
|
|
278
|
+
return t.startsWith('```')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A blockquote line. */
|
|
282
|
+
function isBlockquoteLine(line: string): boolean {
|
|
283
|
+
return line.trimStart().startsWith('>')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** An ATX heading line. */
|
|
287
|
+
function isHeadingLine(line: string): boolean {
|
|
288
|
+
return /^#{1,6}\s/.test(line.trimStart())
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Insert a blank line at block boundaries that are currently separated by
|
|
293
|
+
* exactly one `\n`. Operates line-by-line on already-code-masked text.
|
|
294
|
+
*
|
|
295
|
+
* A blank line is guaranteed:
|
|
296
|
+
* - BEFORE the first row of a GFM table (a `|`-leading line that is itself a
|
|
297
|
+
* delimiter row, OR a `|`-containing header line immediately followed by a
|
|
298
|
+
* delimiter row) when the previous emitted line is non-blank and not part
|
|
299
|
+
* of a table — never between a table's own header/delimiter/body rows.
|
|
300
|
+
* - BEFORE a fenced-code open, a blockquote, or an ATX heading when the
|
|
301
|
+
* previous line is non-blank and of a DIFFERENT block type.
|
|
302
|
+
* - AFTER a list block: when a list-item line is followed by a non-blank
|
|
303
|
+
* line that is NOT itself a list item and NOT an indented continuation of
|
|
304
|
+
* the item (4+ leading spaces / a tab), so the prose breaks out of the list.
|
|
305
|
+
*
|
|
306
|
+
* Conservative: prefers a false negative (leave glued) over corrupting a valid
|
|
307
|
+
* block. Existing blank lines (empty entries from a `\n\n` gap) are preserved
|
|
308
|
+
* and short-circuit every rule — we never double up a gap.
|
|
309
|
+
*/
|
|
310
|
+
function ensureBlockBoundaries(text: string, placeholder?: string): string {
|
|
311
|
+
if (!text.includes('\n')) return text
|
|
312
|
+
const lines = text.split('\n')
|
|
313
|
+
const result: string[] = []
|
|
314
|
+
|
|
315
|
+
for (let i = 0; i < lines.length; i++) {
|
|
316
|
+
const line = lines[i]
|
|
317
|
+
const prev = result.length > 0 ? result[result.length - 1] : null
|
|
318
|
+
const prevNonBlank = prev != null && prev.trim() !== ''
|
|
319
|
+
const curBlank = line.trim() === ''
|
|
320
|
+
|
|
321
|
+
// ---- Rule A: blank line BEFORE a block that needs one to start ----
|
|
322
|
+
if (prevNonBlank && !curBlank) {
|
|
323
|
+
const next = i + 1 < lines.length ? lines[i + 1] : ''
|
|
324
|
+
|
|
325
|
+
// Table first row: either THIS line is a delimiter row (header was the
|
|
326
|
+
// prev line — but only treat as a table start when prev itself isn't
|
|
327
|
+
// already a table row), or THIS line is a `|`-bearing header whose NEXT
|
|
328
|
+
// line is a delimiter. We anchor the blank-line insertion on the HEADER
|
|
329
|
+
// line so header+delimiter+body stay contiguous.
|
|
330
|
+
const prevIsTable = isTableRowLine(prev)
|
|
331
|
+
const startsTableHere =
|
|
332
|
+
!prevIsTable &&
|
|
333
|
+
((line.includes('|') && isTableDelimiterLine(next)) ||
|
|
334
|
+
(isTableRowLine(line) && isTableDelimiterLine(next)))
|
|
335
|
+
|
|
336
|
+
const startsFence = isFenceOpenLine(line, placeholder) && !isFenceOpenLine(prev, placeholder)
|
|
337
|
+
const startsQuote = isBlockquoteLine(line) && !isBlockquoteLine(prev)
|
|
338
|
+
const startsHeading = isHeadingLine(line) && !isHeadingLine(prev)
|
|
339
|
+
|
|
340
|
+
if (startsTableHere || startsFence || startsQuote || startsHeading) {
|
|
341
|
+
result.push('')
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- Rule B: blank line AFTER a list block, before breakout prose ----
|
|
346
|
+
if (prevNonBlank && !curBlank && isListItemLine(prev) && !isListItemLine(line)) {
|
|
347
|
+
// A 4+ space (or tab) indent means `line` is a lazy continuation of the
|
|
348
|
+
// list item's paragraph, NOT breakout prose — leave it glued.
|
|
349
|
+
const isIndentedContinuation = /^(\t| {4,})\S/.test(line)
|
|
350
|
+
// A table/fence/quote/heading start is already handled by Rule A above
|
|
351
|
+
// (its blank line was just inserted); avoid inserting a second one.
|
|
352
|
+
const alreadySeparated = result.length > 0 && result[result.length - 1].trim() === ''
|
|
353
|
+
if (!isIndentedContinuation && !alreadySeparated) {
|
|
354
|
+
result.push('')
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
result.push(line)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return result.join('\n')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Lines that introduce GFM block structure — never reflow around these. */
|
|
365
|
+
function isMarkerLine(line: string, placeholder?: string): boolean {
|
|
366
|
+
// CommonMark indented code block: 4+ leading spaces then a non-space char.
|
|
367
|
+
// Checked on the RAW (pre-trim) line — trimming would erase the very indent
|
|
368
|
+
// that makes it a code block, so we must look before `trimStart()`.
|
|
369
|
+
if (/^ {4,}\S/.test(line)) return true
|
|
370
|
+
const t = line.trimStart()
|
|
371
|
+
// A line that begins with the code-mask placeholder is a standalone masked
|
|
372
|
+
// fenced block — treat it as a block marker so we never inject a hard break
|
|
373
|
+
// immediately before/after a code block. (An INLINE code span sits mid-line,
|
|
374
|
+
// so the line won't START with the placeholder and ordinary prose rules apply.)
|
|
375
|
+
if (placeholder != null && placeholder.length > 0 && t.startsWith(placeholder)) return true
|
|
376
|
+
return (
|
|
377
|
+
// Unordered list bullet: -, *, + followed by a space.
|
|
378
|
+
/^[-*+]\s/.test(t) ||
|
|
379
|
+
// Ordered list: `1.` or `1)` followed by a space.
|
|
380
|
+
/^\d+[.)]\s/.test(t) ||
|
|
381
|
+
// Blockquote / pull-quote.
|
|
382
|
+
t.startsWith('>') ||
|
|
383
|
+
// ATX heading.
|
|
384
|
+
/^#{1,6}\s/.test(t) ||
|
|
385
|
+
// Table row (leading pipe) or table-ish line (interior ` | `).
|
|
386
|
+
t.startsWith('|') ||
|
|
387
|
+
line.includes(' | ') ||
|
|
388
|
+
// Fenced code delimiter (defensive — fences are masked, but a lone/odd
|
|
389
|
+
// fence line can survive masking).
|
|
390
|
+
t.startsWith('```') ||
|
|
391
|
+
// Thematic break / divider.
|
|
392
|
+
/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Decide whether the lone `\n` between `prev` and `next` is a genuine prose
|
|
398
|
+
* paragraph break worth promoting to a GFM hard break. Conservative by design
|
|
399
|
+
* (see normalizeParagraphBreaks doc) — returns false on any doubt.
|
|
400
|
+
*/
|
|
401
|
+
function shouldPromoteBreak(prev: string, next: string, placeholder?: string): boolean {
|
|
402
|
+
if (isMarkerLine(prev, placeholder) || isMarkerLine(next, placeholder)) return false
|
|
403
|
+
// Next line must begin with ordinary prose, not a structural marker char.
|
|
404
|
+
const nextTrimmed = next.trimStart()
|
|
405
|
+
if (nextTrimmed.length === 0) return false
|
|
406
|
+
// The preceding line must read as a finished sentence/clause: it ends in a
|
|
407
|
+
// sentence-terminal punctuation mark, optionally wrapped by a closing quote
|
|
408
|
+
// or paren that itself follows such a terminator.
|
|
409
|
+
const prevTrimmed = prev.trimEnd()
|
|
410
|
+
// Strip up to one trailing closing-bracket/quote run to look at the real
|
|
411
|
+
// terminator (e.g. `He said "go."` or `(done.)`).
|
|
412
|
+
const unwrapped = prevTrimmed.replace(/[)"'’”\]]+$/, '')
|
|
413
|
+
const terminator = unwrapped.slice(-1)
|
|
414
|
+
return terminator === '.' || terminator === '!' || terminator === '?' || terminator === ':'
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Last-resort hard slicer for a body that `splitMarkdownChunks` could not break
|
|
419
|
+
* (a single indivisible region larger than the cap — e.g. a giant fenced block
|
|
420
|
+
* with no interior boundary). Cuts on raw character count so every emitted
|
|
421
|
+
* piece is guaranteed `<= cap`, accepting that a cut MAY land inside a fence
|
|
422
|
+
* (which Telegram renders imperfectly) — a degraded-but-delivered message beats
|
|
423
|
+
* a hard `RICH_MESSAGE_TEXT_TOO_LONG` reject that drops the answer entirely.
|
|
424
|
+
*
|
|
425
|
+
* Returns the input as a single-element array when it already fits.
|
|
426
|
+
*/
|
|
427
|
+
export function hardSliceToCap(text: string, cap = RICH_MESSAGE_MAX_CHARS): string[] {
|
|
428
|
+
if (cap <= 0) return [text]
|
|
429
|
+
if (text.length <= cap) return [text]
|
|
430
|
+
const out: string[] = []
|
|
431
|
+
for (let i = 0; i < text.length; i += cap) {
|
|
432
|
+
out.push(text.slice(i, i + cap))
|
|
433
|
+
}
|
|
434
|
+
return out
|
|
127
435
|
}
|
|
128
436
|
|
|
129
437
|
// ---------------------------------------------------------------------------
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type DriveApprovalHandlerDeps,
|
|
13
13
|
handleRequestDriveApproval,
|
|
14
14
|
} from "./drive-write-approval.js";
|
|
15
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
15
16
|
|
|
16
17
|
// ────────────────────────────────────────────────────────────────────────
|
|
17
18
|
// Fixtures
|
|
@@ -284,17 +285,16 @@ describe("handleRequestDriveApproval — TTL clamping", () => {
|
|
|
284
285
|
// ────────────────────────────────────────────────────────────────────────
|
|
285
286
|
|
|
286
287
|
describe("handleRequestDriveApproval — oversize card body fit (#1767)", () => {
|
|
287
|
-
it("truncates the rendered text under
|
|
288
|
+
it("truncates the rendered text under the rich-message cap before posting", async () => {
|
|
288
289
|
const spy = makeSpy();
|
|
289
|
-
// buildCard returns a body well past
|
|
290
|
-
// (simulates a docTitle /
|
|
291
|
-
// inflated past the limit). Handler must
|
|
290
|
+
// buildCard returns a body well past the rich-message wire cap
|
|
291
|
+
// (RICH_MESSAGE_MAX_CHARS, 32768 post-#2669 — simulates a docTitle /
|
|
292
|
+
// anchor / summary whose escape inflated past the limit). Handler must
|
|
293
|
+
// shrink it before postCard.
|
|
292
294
|
const giantText =
|
|
293
295
|
"<b>Title</b>\n" +
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
);
|
|
297
|
-
expect(giantText.length).toBeGreaterThan(4096);
|
|
296
|
+
"x".repeat(RICH_MESSAGE_MAX_CHARS + 4000);
|
|
297
|
+
expect(giantText.length).toBeGreaterThan(RICH_MESSAGE_MAX_CHARS);
|
|
298
298
|
await handleRequestDriveApproval(
|
|
299
299
|
clientFor(spy),
|
|
300
300
|
msgFor(),
|
|
@@ -304,7 +304,7 @@ describe("handleRequestDriveApproval — oversize card body fit (#1767)", () =>
|
|
|
304
304
|
}),
|
|
305
305
|
);
|
|
306
306
|
expect(spy.posted).toHaveLength(1);
|
|
307
|
-
expect(spy.posted[0]!.text.length).toBeLessThanOrEqual(
|
|
307
|
+
expect(spy.posted[0]!.text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
|
|
308
308
|
expect(spy.posted[0]!.text).toContain("preview truncated");
|
|
309
309
|
// Card was successfully posted → ok:true response went back.
|
|
310
310
|
expect(spy.sent[0]?.ok).toBe(true);
|
|
@@ -334,7 +334,7 @@ describe("handleRequestDriveApproval — oversize card body fit (#1767)", () =>
|
|
|
334
334
|
it("post failure after truncation surfaces a structured reason", async () => {
|
|
335
335
|
const spy = makeSpy();
|
|
336
336
|
const giantText =
|
|
337
|
-
"<b>Title</b>\n" + "x".repeat(
|
|
337
|
+
"<b>Title</b>\n" + "x".repeat(RICH_MESSAGE_MAX_CHARS + 4000);
|
|
338
338
|
await handleRequestDriveApproval(
|
|
339
339
|
clientFor(spy),
|
|
340
340
|
msgFor(),
|
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
RequestDriveApprovalMessage,
|
|
32
32
|
} from "./ipc-protocol.js";
|
|
33
33
|
import { truncateRawToFit } from "./oversize-card-body.js";
|
|
34
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
34
35
|
|
|
35
36
|
// ────────────────────────────────────────────────────────────────────────
|
|
36
37
|
// Injected deps — caller (gateway.ts) wires these from the existing
|
|
@@ -102,15 +103,20 @@ const MAX_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
102
103
|
const MIN_TTL_MS = 30 * 1000; // 30 seconds
|
|
103
104
|
|
|
104
105
|
/**
|
|
105
|
-
* Telegram
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
106
|
+
* Telegram rich-message hard limit. `buildDiffPreviewCard` escapes the
|
|
107
|
+
* docTitle + every body line with no upstream length cap, so an adversarial
|
|
108
|
+
* or just-unusually-long docTitle / anchor displayName / agent-supplied
|
|
109
|
+
* summary can render past the wire cap once escaping inflates it. We rebuild a
|
|
110
|
+
* truncated body keyed off the wrapper-attested fields when that happens.
|
|
111
|
+
* (#1767)
|
|
112
|
+
*
|
|
113
|
+
* Post-#2669 every card renders as GFM markdown via `sendRichMessage`, so the
|
|
114
|
+
* hard cap is `RICH_MESSAGE_MAX_CHARS` (32768), not the legacy 4096 plain-text
|
|
115
|
+
* limit. RENDERED_BODY_CAP keeps the proportional ~5% headroom under the hard
|
|
116
|
+
* cap that the original 3900-under-4096 budget carried.
|
|
111
117
|
*/
|
|
112
|
-
const TELEGRAM_SENDMESSAGE_LIMIT =
|
|
113
|
-
const RENDERED_BODY_CAP =
|
|
118
|
+
const TELEGRAM_SENDMESSAGE_LIMIT = RICH_MESSAGE_MAX_CHARS;
|
|
119
|
+
const RENDERED_BODY_CAP = RICH_MESSAGE_MAX_CHARS - 200;
|
|
114
120
|
const OVERSIZE_SENTINEL = "\n[… preview truncated; open in Drive for full context]";
|
|
115
121
|
|
|
116
122
|
/**
|