switchroom 0.16.29 → 0.16.46
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/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -53,6 +53,24 @@ export function escapeMarkdown(text: string): string {
|
|
|
53
53
|
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Make a string safe to interpolate INSIDE a `code span`.
|
|
58
|
+
*
|
|
59
|
+
* Inside a GFM code span the content is LITERAL — backslash escaping does
|
|
60
|
+
* NOT apply, so `escapeMarkdown` is exactly wrong there: it emits visible
|
|
61
|
+
* backslashes (e.g. `openai\_key` for an identifier containing `_`). The
|
|
62
|
+
* only character that can prematurely CLOSE the span is a backtick, so the
|
|
63
|
+
* sole transform needed is to defuse embedded backticks. We insert a
|
|
64
|
+
* zero-width space after each backtick so the raw ``` ` ``` can no longer
|
|
65
|
+
* terminate the surrounding span while remaining visually identical.
|
|
66
|
+
*
|
|
67
|
+
* This is the canonical home for the helper (#2695 regression fix); other
|
|
68
|
+
* modules re-export from here so there's one implementation.
|
|
69
|
+
*/
|
|
70
|
+
export function codeSpanSafe(s: string): string {
|
|
71
|
+
return s.replace(/`/g, '`')
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
/**
|
|
57
75
|
* Repair LLM-side JSON escape bungles.
|
|
58
76
|
*
|
|
@@ -190,16 +208,93 @@ function maskCodeRegions(text: string, nonce: string): MaskedCode {
|
|
|
190
208
|
* Code fences and inline code are masked out before any of this runs, so their
|
|
191
209
|
* interior `\n`s are never touched.
|
|
192
210
|
*/
|
|
211
|
+
/**
|
|
212
|
+
* Split a collapsed inline bullet list onto separate lines.
|
|
213
|
+
*
|
|
214
|
+
* Agents sometimes emit an entire bullet list on ONE line using interior
|
|
215
|
+
* `•`/`·` separators, e.g.:
|
|
216
|
+
*
|
|
217
|
+
* • Master Bath 1, clean • <b>Master Bath 2</b>, 33% loss • Cabinet, clean
|
|
218
|
+
*
|
|
219
|
+
* which renders as a single run-on line instead of stacked bullets (the GFM
|
|
220
|
+
* rich path treats `•`/`·` as ordinary text, not list syntax, so nothing
|
|
221
|
+
* stacks). This deterministically inserts a newline before each interior
|
|
222
|
+
* unicode-bullet separator so each bullet lands on its own line.
|
|
223
|
+
*
|
|
224
|
+
* Conservative by construction:
|
|
225
|
+
* - Only a line that STARTS (after optional leading whitespace) with a bullet
|
|
226
|
+
* marker (`•`, `·`, `-`, or `*` followed by a space) is eligible — prose
|
|
227
|
+
* with a mid-sentence `•` is left untouched.
|
|
228
|
+
* - Only the unicode bullets `•`/`·` are split on (an interior whitespace +
|
|
229
|
+
* `•`/`·` + whitespace). `-`/`*` are NEVER used as interior split points —
|
|
230
|
+
* too many false positives (hyphens, ranges, "a * b" multiplication).
|
|
231
|
+
* They are only accepted as the LEADING marker.
|
|
232
|
+
* - The bullet glyphs are left as-is; we only insert `\n` before each split
|
|
233
|
+
* bullet and normalize the inter-bullet whitespace to a single space.
|
|
234
|
+
* - Idempotent: once split, each bullet begins its own line, so the
|
|
235
|
+
* interior-separator pattern (whitespace + bullet + whitespace) no longer
|
|
236
|
+
* matches anywhere on those lines.
|
|
237
|
+
*
|
|
238
|
+
* Runs on already-code-masked text, so a `•` inside a code span/fence is safe.
|
|
239
|
+
*/
|
|
240
|
+
export function splitCollapsedInlineBullets(text: string): string {
|
|
241
|
+
if (!/[•·]/.test(text)) return text
|
|
242
|
+
// Leading marker: optional indent, then • · - or * followed by a space.
|
|
243
|
+
// Interior separator to split on: whitespace + • or · + whitespace.
|
|
244
|
+
const interiorSep = /[ \t]+([•·])[ \t]+/g
|
|
245
|
+
return text
|
|
246
|
+
.split('\n')
|
|
247
|
+
.map((line) => {
|
|
248
|
+
if (!/^[ \t]*[•·*-] /.test(line)) return line
|
|
249
|
+
if (!/[ \t][•·][ \t]/.test(line)) return line
|
|
250
|
+
return line.replace(interiorSep, '\n$1 ')
|
|
251
|
+
})
|
|
252
|
+
.join('\n')
|
|
253
|
+
}
|
|
254
|
+
|
|
193
255
|
export function normalizeParagraphBreaks(text: string): string {
|
|
194
|
-
|
|
256
|
+
// A text with no newline still needs the inline-bullet split (a collapsed
|
|
257
|
+
// bullet list is a SINGLE line). Only bail early when there is neither a
|
|
258
|
+
// newline nor a unicode bullet to potentially split.
|
|
259
|
+
if (!text.includes('\n') && !/[•·]/.test(text)) return text
|
|
195
260
|
|
|
196
261
|
const nonce = Math.random().toString(36).slice(2)
|
|
197
|
-
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
262
|
+
const { masked: maskedRaw, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
198
263
|
|
|
199
|
-
// Step
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
264
|
+
// Step 0: split a collapsed inline bullet list onto separate lines. Done on
|
|
265
|
+
// the code-masked text so a `•` inside a fenced block or inline code span is
|
|
266
|
+
// never touched. See splitCollapsedInlineBullets for the exact rule.
|
|
267
|
+
const masked = splitCollapsedInlineBullets(maskedRaw)
|
|
268
|
+
|
|
269
|
+
// Step 1: collapse blank-line runs to exactly ONE clean blank line (`\n\n`),
|
|
270
|
+
// never leaving a whitespace-only line between two paragraphs.
|
|
271
|
+
//
|
|
272
|
+
// (a) Pure newline runs of 3+ → `\n\n`.
|
|
273
|
+
// (b) A blank-line run whose interior lines are ASCII-whitespace-only
|
|
274
|
+
// (spaces / tabs / CR) → `\n\n`. A model (or an upstream transform)
|
|
275
|
+
// that authors `A\n\n \n\nB` leaves a lone-space line between the
|
|
276
|
+
// paragraphs; CommonMark discards it (so it buys no gap) but it reads
|
|
277
|
+
// as an oversized / ragged gap in the raw text and in some clients, and
|
|
278
|
+
// it was the "stray blank line" seen in real replies. Collapse it.
|
|
279
|
+
//
|
|
280
|
+
// Deliberately ASCII-only: a line whose only content is U+00A0 is the
|
|
281
|
+
// INTENTIONAL, non-collapsible paragraph spacer added later by
|
|
282
|
+
// addParagraphSpacers (#2692) to force a visible gap on the rich-message
|
|
283
|
+
// path. This step runs BEFORE that spacer pass and must never eat a U+00A0
|
|
284
|
+
// line, so the `[ \t\r]` character class here excludes U+00A0 by
|
|
285
|
+
// construction. Runs on code-masked text, so a blank-ish line inside a
|
|
286
|
+
// fenced block is parked and never touched.
|
|
287
|
+
let out = masked
|
|
288
|
+
// Collapse any run of newlines interleaved with ASCII whitespace-only
|
|
289
|
+
// interior lines down to a single clean `\n\n`. Requires at least one
|
|
290
|
+
// whitespace char between the first two newlines OR 3+ newlines, so it
|
|
291
|
+
// fires on both `A\n \nB` (one space-only blank line) and `A\n\n \n\nB`
|
|
292
|
+
// (a space-only line inside a multi-blank run) but never rewrites a clean
|
|
293
|
+
// `A\n\nB` (no interior whitespace) — that already-correct gap is left to
|
|
294
|
+
// the `\n{3,}` pass below, which collapses any surviving run of 3+
|
|
295
|
+
// newlines (including pure `A\n\n\n\nB`) down to a single `\n\n`.
|
|
296
|
+
.replace(/\n[ \t\r]+\n(?:[ \t\r]*\n)*/g, '\n\n')
|
|
297
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
203
298
|
|
|
204
299
|
// Step 2: walk lines and promote lone prose breaks. We rebuild the string by
|
|
205
300
|
// joining lines with the right separator. A separator is "hard" (` \n`) only
|
|
@@ -236,8 +331,10 @@ export function normalizeParagraphBreaks(text: string): string {
|
|
|
236
331
|
// Step 3: guarantee a blank line (`\n\n`) at BLOCK BOUNDARIES. The
|
|
237
332
|
// prose-promotion above keeps lists/tables tight by leaving their single
|
|
238
333
|
// `\n` separators alone — but GFM's rich renderer needs a blank line to
|
|
239
|
-
// START a new block
|
|
240
|
-
//
|
|
334
|
+
// START a new block. A properly block-separated table DOES render as a
|
|
335
|
+
// real table (Bot API 10.1 rich messages); the failure mode this pass
|
|
336
|
+
// fixes is the *glued* case — a block joined to the previous line by a
|
|
337
|
+
// single `\n` (an un-separated table degrades to literal pipe text, prose
|
|
241
338
|
// after a list is absorbed as a lazy list continuation). This pass inserts
|
|
242
339
|
// the missing blank line at those transitions only, on the same masked text,
|
|
243
340
|
// never touching code interiors, never collapsing/expanding existing `\n\n`,
|
|
@@ -247,6 +344,334 @@ export function normalizeParagraphBreaks(text: string): string {
|
|
|
247
344
|
return restore(out)
|
|
248
345
|
}
|
|
249
346
|
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Paragraph spacers — restore a VISIBLE blank line between prose paragraphs
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The non-collapsible spacer paragraph injected between two prose paragraphs.
|
|
353
|
+
*
|
|
354
|
+
* Telegram's Bot API 10.1 rich-message renderer (the GFM/CommonMark engine
|
|
355
|
+
* behind `sendRichMessage` / `editMessageText({ markdown })`) renders a `\n\n`
|
|
356
|
+
* paragraph break TIGHT — the two paragraphs sit on adjacent lines with no
|
|
357
|
+
* visible empty line between them. The legacy markdown→HTML path (removed in
|
|
358
|
+
* #2669) sent `\n\n` literally with `parse_mode:"HTML"`, where two newlines
|
|
359
|
+
* render as a real blank line. That regression is the operator-confirmed
|
|
360
|
+
* "paragraphs jammed together" symptom.
|
|
361
|
+
*
|
|
362
|
+
* CommonMark discards blank lines made of ASCII whitespace, but a line whose
|
|
363
|
+
* only content is a NON-breaking space (U+00A0) is a genuine, non-empty
|
|
364
|
+
* paragraph — it renders as a visible empty line. So `A\n\n \n\nB`
|
|
365
|
+
* renders as three paragraphs: A, a blank-looking line, then B — the visible
|
|
366
|
+
* gap the HTML path used to produce.
|
|
367
|
+
*/
|
|
368
|
+
export const PARAGRAPH_SPACER = ' '
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Insert a visible blank-line spacer into each genuine `\n\n` paragraph gap so
|
|
372
|
+
* the rich GFM renderer shows a real empty line between paragraphs (matching
|
|
373
|
+
* the pre-#2669 HTML behaviour). See PARAGRAPH_SPACER for why a U+00A0 line is
|
|
374
|
+
* the reliable trick.
|
|
375
|
+
*
|
|
376
|
+
* Uniform-block-spacing contract: a spacer is inserted into EVERY `\n\n` gap
|
|
377
|
+
* that separates two DISTINCT blocks — prose→prose, paragraph→list,
|
|
378
|
+
* list→paragraph, heading→anything, blockquote/table/fence boundaries — so a
|
|
379
|
+
* mixed message renders with one identical visible blank line between blocks.
|
|
380
|
+
* The one exception is a gap INSIDE a block of the same structural kind (two
|
|
381
|
+
* items of a loose list, consecutive table rows/quotes/fences): those stay
|
|
382
|
+
* tight so the block's contiguity survives. Interiors joined by a single `\n`
|
|
383
|
+
* are never gaps at all and are untouched by construction.
|
|
384
|
+
*
|
|
385
|
+
* Runs on code-masked text (so a blank line inside a fenced block is never
|
|
386
|
+
* touched) and is idempotent — a gap that already contains a U+00A0 spacer
|
|
387
|
+
* paragraph is recognised and never doubled.
|
|
388
|
+
*
|
|
389
|
+
* Intended to run in the outbound send path AFTER normalizeParagraphBreaks,
|
|
390
|
+
* which has already collapsed 3+ newline runs to `\n\n`, promoted lone prose
|
|
391
|
+
* breaks, and guaranteed block-boundary blank lines. normalizeParagraphBreaks
|
|
392
|
+
* itself deliberately does NOT do this so its (well-tested) `\n\n`-preserving
|
|
393
|
+
* contract is unchanged.
|
|
394
|
+
*/
|
|
395
|
+
export function addParagraphSpacers(text: string): string {
|
|
396
|
+
if (!text.includes('\n\n')) return text
|
|
397
|
+
|
|
398
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
399
|
+
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
400
|
+
|
|
401
|
+
if (!masked.includes('\n\n')) return restore(masked)
|
|
402
|
+
|
|
403
|
+
// The line we inject for a spacer paragraph (its only content is U+00A0).
|
|
404
|
+
const spacerLine = PARAGRAPH_SPACER
|
|
405
|
+
|
|
406
|
+
// CRITICAL: `String.prototype.trim()` strips U+00A0, so a spacer line would
|
|
407
|
+
// read as "blank" and the pass would lose idempotency (re-spacing an
|
|
408
|
+
// already-spaced gap). Detect blank-ness with an ASCII-whitespace-only test
|
|
409
|
+
// so the U+00A0 spacer line is correctly seen as NON-blank.
|
|
410
|
+
const isBlankLine = (line: string): boolean => /^[ \t\r\f\v]*$/.test(line)
|
|
411
|
+
// Trim ASCII-only (preserve U+00A0) so the spacer line is recognisable.
|
|
412
|
+
const asciiTrim = (line: string): string => line.replace(/^[ \t\r\f\v]+|[ \t\r\f\v]+$/g, '')
|
|
413
|
+
|
|
414
|
+
// Classify the block kind of a facing line so the spacer decision can be
|
|
415
|
+
// made per BLOCK TRANSITION (#uniform-block-spacing). A spacer is inserted
|
|
416
|
+
// at every `\n\n` gap between two DIFFERENT block kinds (paragraph→list,
|
|
417
|
+
// list→paragraph, heading→anything, blockquote/table boundaries) and
|
|
418
|
+
// between two prose paragraphs — but NEVER inside a single block's interior
|
|
419
|
+
// (between two items of the same loose list, two rows of a table, two
|
|
420
|
+
// quote lines, two fenced blocks). One visible blank line between distinct
|
|
421
|
+
// blocks, identical everywhere; list/table interiors stay tight.
|
|
422
|
+
type BlockKind = 'spacer' | 'list' | 'table' | 'quote' | 'heading' | 'fence' | 'divider' | 'prose'
|
|
423
|
+
const blockKind = (line: string): BlockKind => {
|
|
424
|
+
if (asciiTrim(line) === spacerLine) return 'spacer'
|
|
425
|
+
if (isFenceOpenLine(line, placeholder)) return 'fence'
|
|
426
|
+
if (isListItemLine(line)) return 'list'
|
|
427
|
+
if (isTableRowLine(line) || isTableDelimiterLine(line)) return 'table'
|
|
428
|
+
if (isBlockquoteLine(line)) return 'quote'
|
|
429
|
+
if (isHeadingLine(line)) return 'heading'
|
|
430
|
+
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line.trimStart())) return 'divider'
|
|
431
|
+
return 'prose'
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Same-kind structural pairs whose `\n\n` gap is a block INTERIOR (a loose
|
|
435
|
+
// list's item gap, consecutive tables/quotes/fences) — no spacer there.
|
|
436
|
+
const SAME_KIND_TIGHT: ReadonlySet<BlockKind> = new Set([
|
|
437
|
+
'list',
|
|
438
|
+
'table',
|
|
439
|
+
'quote',
|
|
440
|
+
'fence',
|
|
441
|
+
'divider',
|
|
442
|
+
])
|
|
443
|
+
|
|
444
|
+
const shouldSpaceGap = (above: string, below: string): boolean => {
|
|
445
|
+
const a = blockKind(above)
|
|
446
|
+
const b = blockKind(below)
|
|
447
|
+
// A facing spacer line means the gap is already spaced (idempotency —
|
|
448
|
+
// also guarded by alreadySpaced at the call site).
|
|
449
|
+
if (a === 'spacer' || b === 'spacer') return false
|
|
450
|
+
if (a === b && SAME_KIND_TIGHT.has(a)) return false
|
|
451
|
+
// Everything else is a genuine block transition (incl. prose→prose,
|
|
452
|
+
// heading→anything, list↔paragraph, table/quote boundaries) — space it.
|
|
453
|
+
return true
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Split into blank-line-delimited segments, then re-join inserting a spacer
|
|
457
|
+
// paragraph between two adjacent NON-blank segments whose facing lines are
|
|
458
|
+
// both prose and which are not already separated by a spacer.
|
|
459
|
+
// A `\n\n` paragraph gap is a SINGLE blank entry between two content lines
|
|
460
|
+
// (`"A\n\nB".split('\n')` → `["A", "", "B"]`). normalizeParagraphBreaks has
|
|
461
|
+
// already collapsed 3+ newline runs to exactly `\n\n`, so we only ever see a
|
|
462
|
+
// one-blank gap here; a multi-blank run is handled defensively the same way
|
|
463
|
+
// (the FIRST blank of the run carries the spacer decision).
|
|
464
|
+
const lines = masked.split('\n')
|
|
465
|
+
const out: string[] = []
|
|
466
|
+
for (let i = 0; i < lines.length; i++) {
|
|
467
|
+
const line = lines[i]
|
|
468
|
+
const isBlank = isBlankLine(line)
|
|
469
|
+
// The spacer decision is made at the FIRST blank of a gap, i.e. when the
|
|
470
|
+
// previously emitted line is non-blank prose. Inject the spacer BEFORE the
|
|
471
|
+
// blank so the result is `above \n\n \n\n below`.
|
|
472
|
+
if (isBlank) {
|
|
473
|
+
const prevEmitted = out.length > 0 ? out[out.length - 1] : null
|
|
474
|
+
const prevIsBlank = prevEmitted != null && isBlankLine(prevEmitted)
|
|
475
|
+
if (!prevIsBlank) {
|
|
476
|
+
const above = lastNonBlank(out, isBlankLine)
|
|
477
|
+
const below = nextNonBlank(lines, i + 1, isBlankLine)
|
|
478
|
+
const alreadySpaced =
|
|
479
|
+
(above != null && asciiTrim(above) === spacerLine) ||
|
|
480
|
+
(below != null && asciiTrim(below) === spacerLine)
|
|
481
|
+
if (
|
|
482
|
+
!alreadySpaced &&
|
|
483
|
+
above != null &&
|
|
484
|
+
below != null &&
|
|
485
|
+
shouldSpaceGap(above, below)
|
|
486
|
+
) {
|
|
487
|
+
// Emit: blank, spacer paragraph, blank — a U+00A0 paragraph wedged
|
|
488
|
+
// between two real blank lines so CommonMark renders it as a visible
|
|
489
|
+
// empty line between the two prose paragraphs.
|
|
490
|
+
out.push('')
|
|
491
|
+
out.push(spacerLine)
|
|
492
|
+
out.push('')
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
out.push(line)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return restore(out.join('\n'))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Last non-blank entry already emitted into `arr`, or null. `isBlank` is the
|
|
505
|
+
* caller's blank test (ASCII-only, so a U+00A0 spacer line counts as
|
|
506
|
+
* non-blank — `String.trim()` would wrongly strip it).
|
|
507
|
+
*/
|
|
508
|
+
function lastNonBlank(arr: string[], isBlank: (s: string) => boolean): string | null {
|
|
509
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
510
|
+
if (!isBlank(arr[i])) return arr[i]
|
|
511
|
+
}
|
|
512
|
+
return null
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** First non-blank line at or after index `from` in `lines`, or null. */
|
|
516
|
+
function nextNonBlank(
|
|
517
|
+
lines: string[],
|
|
518
|
+
from: number,
|
|
519
|
+
isBlank: (s: string) => boolean,
|
|
520
|
+
): string | null {
|
|
521
|
+
for (let i = from; i < lines.length; i++) {
|
|
522
|
+
if (!isBlank(lines[i])) return lines[i]
|
|
523
|
+
}
|
|
524
|
+
return null
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
// Punctuation / bullet normalization — fleet-wide consistent typography
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Deterministic punctuation + bullet normalization for outbound messages
|
|
533
|
+
* (fleet-wide consistent Telegram formatting). Runs in the send path AFTER
|
|
534
|
+
* normalizeParagraphBreaks, on code-masked text (reuses maskCodeRegions), so
|
|
535
|
+
* code spans and fenced blocks are never touched.
|
|
536
|
+
*
|
|
537
|
+
* Transforms:
|
|
538
|
+
* 1. Space-flanked em/en dash (` — ` / ` – `) → `, ` — except a
|
|
539
|
+
* digit-flanked spaced dash (a numeric range `3 – 5`), which becomes a
|
|
540
|
+
* plain hyphen range (`3-5`).
|
|
541
|
+
* 2. Bare em-dash between word characters (`word—word`) → `, `; a
|
|
542
|
+
* digit-flanked one (`3—5`) → hyphen.
|
|
543
|
+
* 3. Bare en-dash between word characters → ASCII hyphen (`2019–2024` →
|
|
544
|
+
* `2019-2024`).
|
|
545
|
+
* 4. Leading unicode bullets (`•` / `·`) as list markers → `- ` so every
|
|
546
|
+
* list renders as a real GFM list (indent preserved).
|
|
547
|
+
*
|
|
548
|
+
* Idempotent: the output contains no em/en dashes or leading unicode bullets
|
|
549
|
+
* outside code regions, so a second pass is a no-op.
|
|
550
|
+
*/
|
|
551
|
+
export function normalizePunctuation(text: string): string {
|
|
552
|
+
if (!/[—–•·]/.test(text)) return text
|
|
553
|
+
|
|
554
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
555
|
+
const { masked, restore } = maskCodeRegions(text, nonce)
|
|
556
|
+
|
|
557
|
+
let out = masked
|
|
558
|
+
// 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
|
|
559
|
+
// flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
|
|
560
|
+
// dashes ("a — b — c") all normalize in one pass — a consumed \S would
|
|
561
|
+
// swallow the char that anchors the next match.
|
|
562
|
+
.replace(/(\S)[ \t][—–][ \t](?=(\S))/g, (_m, a: string, b: string) =>
|
|
563
|
+
/\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
|
|
564
|
+
)
|
|
565
|
+
// 2. Bare em-dash between word chars. Numeric range keeps a hyphen.
|
|
566
|
+
// Right flank is a lookahead for the same consecutive-match reason.
|
|
567
|
+
.replace(/(\w)—(?=(\w))/g, (_m, a: string, b: string) =>
|
|
568
|
+
/\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
|
|
569
|
+
)
|
|
570
|
+
// 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
|
|
571
|
+
.replace(/(\w)–(?=\w)/g, '$1-')
|
|
572
|
+
|
|
573
|
+
// 4. Leading unicode bullet markers → GFM `- ` (per line, indent kept).
|
|
574
|
+
out = out
|
|
575
|
+
.split('\n')
|
|
576
|
+
.map((line) => line.replace(/^([ \t]*)[•·][ \t]+/, '$1- '))
|
|
577
|
+
.join('\n')
|
|
578
|
+
|
|
579
|
+
return restore(out)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Over-bold tripwire — strip bold when a message is clearly over-bolded
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
/** A line's content once a leading list marker (`- ` / `1. `) is removed. */
|
|
587
|
+
function listItemContent(line: string): string {
|
|
588
|
+
return line.trimStart().replace(/^(?:[-*+]|\d+[.)])\s+/, '')
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** True when a text fragment is one single fully-bolded span (`**…**`). */
|
|
592
|
+
function isFullyBolded(fragment: string): boolean {
|
|
593
|
+
return /^\*\*[^*]+\*\*[.,:;!?]?$/.test(fragment.trim())
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Strip `**bold**` markers from a fragment, keeping the text. */
|
|
597
|
+
function unbold(fragment: string): string {
|
|
598
|
+
return fragment.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Over-bold tripwire (fleet-wide consistent Telegram formatting). If a
|
|
603
|
+
* message is clearly over-bolded, strip the `**` markers and keep the text:
|
|
604
|
+
*
|
|
605
|
+
* - GLOBAL: when >30% of the message's non-code characters sit inside
|
|
606
|
+
* `**bold**` spans, ALL bold markers are stripped.
|
|
607
|
+
* - PER-BLOCK: an entire multi-line paragraph fully bolded, or a list whose
|
|
608
|
+
* EVERY item is fully bolded, has that block's bold stripped.
|
|
609
|
+
*
|
|
610
|
+
* Deliberately conservative:
|
|
611
|
+
* - Messages under 100 non-code characters are exempt (a short reply whose
|
|
612
|
+
* one key fact is bolded is exactly the house style).
|
|
613
|
+
* - A single-line fully-bolded paragraph of ≤48 chars is treated as a
|
|
614
|
+
* pseudo-heading (the "**Section**" label the fleet style encourages) and
|
|
615
|
+
* is NOT stripped by the per-block rule (it still counts toward the
|
|
616
|
+
* global ratio).
|
|
617
|
+
* - A list with any non-fully-bolded item is left alone.
|
|
618
|
+
*
|
|
619
|
+
* Code spans/fences are masked (maskCodeRegions) and never counted or
|
|
620
|
+
* modified. Idempotent: stripped output has no `**` spans left to trip on.
|
|
621
|
+
*/
|
|
622
|
+
export function stripExcessBold(text: string): string {
|
|
623
|
+
if (!text.includes('**')) return text
|
|
624
|
+
|
|
625
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
626
|
+
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
627
|
+
|
|
628
|
+
// Non-code character budget: masked text with the placeholders removed.
|
|
629
|
+
const placeholderRe = new RegExp(
|
|
630
|
+
`${placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\d+\x00`,
|
|
631
|
+
'g',
|
|
632
|
+
)
|
|
633
|
+
const visible = masked.replace(placeholderRe, '')
|
|
634
|
+
if (visible.length < 100) return restore(masked)
|
|
635
|
+
|
|
636
|
+
let boldChars = 0
|
|
637
|
+
for (const m of visible.matchAll(/\*\*([^*]+)\*\*/g)) boldChars += m[1].length
|
|
638
|
+
|
|
639
|
+
if (boldChars / visible.length > 0.3) {
|
|
640
|
+
// Clearly over-bolded — strip every bold span, keep the text.
|
|
641
|
+
return restore(unbold(masked))
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Per-block check on blank-line-delimited blocks of the masked text.
|
|
645
|
+
const blocks = masked.split(/\n{2,}/)
|
|
646
|
+
const rebuilt = blocks.map((block) => {
|
|
647
|
+
const lines = block.split('\n').filter((l) => l.trim() !== '')
|
|
648
|
+
if (lines.length === 0 || !block.includes('**')) return block
|
|
649
|
+
|
|
650
|
+
const listLines = lines.filter((l) => isListItemLine(l))
|
|
651
|
+
if (listLines.length >= 2 && listLines.length === lines.length) {
|
|
652
|
+
// A list block: strip only when EVERY item is fully bolded.
|
|
653
|
+
if (lines.every((l) => isFullyBolded(listItemContent(l)))) return unbold(block)
|
|
654
|
+
return block
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// A prose paragraph (no structural marker lines): strip when every line
|
|
658
|
+
// is one fully-bolded span — except the single-short-line pseudo-heading.
|
|
659
|
+
const isProseBlock = lines.every((l) => !isMarkerLine(l, placeholder))
|
|
660
|
+
if (!isProseBlock) return block
|
|
661
|
+
if (!lines.every((l) => isFullyBolded(l))) return block
|
|
662
|
+
if (lines.length === 1 && lines[0].trim().length <= 48) return block
|
|
663
|
+
return unbold(block)
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
// Rejoin with the original gap shapes: split() lost them, so re-split the
|
|
667
|
+
// masked text capturing the separators and interleave.
|
|
668
|
+
const seps = masked.match(/\n{2,}/g) ?? []
|
|
669
|
+
let out = rebuilt[0] ?? ''
|
|
670
|
+
for (let i = 1; i < rebuilt.length; i++) out += (seps[i - 1] ?? '\n\n') + rebuilt[i]
|
|
671
|
+
|
|
672
|
+
return restore(out)
|
|
673
|
+
}
|
|
674
|
+
|
|
250
675
|
// ---------------------------------------------------------------------------
|
|
251
676
|
// Block-boundary blank-line guarantee (Step 3 of normalizeParagraphBreaks)
|
|
252
677
|
// ---------------------------------------------------------------------------
|
|
@@ -336,8 +761,34 @@ function ensureBlockBoundaries(text: string, placeholder?: string): string {
|
|
|
336
761
|
const startsFence = isFenceOpenLine(line, placeholder) && !isFenceOpenLine(prev, placeholder)
|
|
337
762
|
const startsQuote = isBlockquoteLine(line) && !isBlockquoteLine(prev)
|
|
338
763
|
const startsHeading = isHeadingLine(line) && !isHeadingLine(prev)
|
|
764
|
+
// List start glued to prose above by a single `\n` (uniform-block-
|
|
765
|
+
// spacing): a `- ` line already interrupts a paragraph in CommonMark,
|
|
766
|
+
// so the blank line is render-safe — it only lets the spacer pass see
|
|
767
|
+
// the transition. Never fires between two list items (prev is a list
|
|
768
|
+
// item) so list interiors stay tight.
|
|
769
|
+
const startsList = isListItemLine(line) && !isListItemLine(prev)
|
|
339
770
|
|
|
340
|
-
if (startsTableHere || startsFence || startsQuote || startsHeading) {
|
|
771
|
+
if (startsTableHere || startsFence || startsQuote || startsHeading || startsList) {
|
|
772
|
+
result.push('')
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ---- Rule A2: blank line AFTER a closed code fence, before prose ----
|
|
777
|
+
// Prose glued directly onto a fence's close (single `\n`) can be swallowed
|
|
778
|
+
// or mis-parsed; a blank line after a closed fence is always CommonMark-safe.
|
|
779
|
+
// Masked blocks collapse to a single placeholder-leading line, so a fence
|
|
780
|
+
// "close" is a prev line that opens a fence (masked placeholder or literal
|
|
781
|
+
// ``` fence). Only fire when the current line is non-blank prose that is NOT
|
|
782
|
+
// itself a new block start (those are handled by Rule A above).
|
|
783
|
+
if (prevNonBlank && !curBlank && isFenceOpenLine(prev, placeholder)) {
|
|
784
|
+
const alreadySeparated = result.length > 0 && result[result.length - 1].trim() === ''
|
|
785
|
+
const curIsBlockStart =
|
|
786
|
+
isFenceOpenLine(line, placeholder) ||
|
|
787
|
+
isBlockquoteLine(line) ||
|
|
788
|
+
isHeadingLine(line) ||
|
|
789
|
+
isTableRowLine(line) ||
|
|
790
|
+
isTableDelimiterLine(line)
|
|
791
|
+
if (!alreadySeparated && !curIsBlockStart) {
|
|
341
792
|
result.push('')
|
|
342
793
|
}
|
|
343
794
|
}
|
|
@@ -382,9 +833,13 @@ function isMarkerLine(line: string, placeholder?: string): boolean {
|
|
|
382
833
|
t.startsWith('>') ||
|
|
383
834
|
// ATX heading.
|
|
384
835
|
/^#{1,6}\s/.test(t) ||
|
|
385
|
-
// Table row (leading pipe) or table
|
|
386
|
-
|
|
387
|
-
|
|
836
|
+
// Table row (leading pipe) or a table delimiter row. A loose interior
|
|
837
|
+
// ` | ` substring misclassifies prose like `choose A | B` as a table row
|
|
838
|
+
// and suppresses paragraph spacing — require a real GFM table row/delimiter
|
|
839
|
+
// instead. (A header row lacking a leading pipe is still recognised as
|
|
840
|
+
// structural when its delimiter row follows, via ensureBlockBoundaries.)
|
|
841
|
+
isTableRowLine(line) ||
|
|
842
|
+
isTableDelimiterLine(line) ||
|
|
388
843
|
// Fenced code delimiter (defensive — fences are masked, but a lone/odd
|
|
389
844
|
// fence line can survive masking).
|
|
390
845
|
t.startsWith('```') ||
|
|
@@ -411,7 +866,41 @@ function shouldPromoteBreak(prev: string, next: string, placeholder?: string): b
|
|
|
411
866
|
// terminator (e.g. `He said "go."` or `(done.)`).
|
|
412
867
|
const unwrapped = prevTrimmed.replace(/[)"'’”\]]+$/, '')
|
|
413
868
|
const terminator = unwrapped.slice(-1)
|
|
414
|
-
|
|
869
|
+
if (terminator === '.' || terminator === '!' || terminator === '?' || terminator === ':') {
|
|
870
|
+
return true
|
|
871
|
+
}
|
|
872
|
+
// Stat-card / key-value promotion (#2750). A vertical card like
|
|
873
|
+
// `Calories: 1800 / Protein: 120g / Carbs: 200g` (or the fleet's HOUSE style
|
|
874
|
+
// `**Calories:** 1800 / **Protein:** 120g`) has lines ending in digits/
|
|
875
|
+
// letters, so the terminator rule above never fires and the whole card
|
|
876
|
+
// collapses into one wall of text. Promote a lone `\n` when the prev line
|
|
877
|
+
// reads as a standalone `label: value` stat line.
|
|
878
|
+
//
|
|
879
|
+
// The discriminator that separates a stat line from a mid-sentence soft wrap
|
|
880
|
+
// that merely contains a colon (`The deal has one catch: the buyer wants it
|
|
881
|
+
// and\nToronto lawyers sign off`) is two-fold:
|
|
882
|
+
// 1. The LABEL (text before the colon) is SHORT — a stat label is a term
|
|
883
|
+
// of a few words, a wrapped prose clause is a long run of words. We cap
|
|
884
|
+
// the label at 3 words / 24 chars.
|
|
885
|
+
// 2. The next line is NOT a lowercase-started mid-sentence continuation.
|
|
886
|
+
// A genuine wrapped sentence continues in lowercase; a stat/label line
|
|
887
|
+
// is followed by another label or capitalised prose.
|
|
888
|
+
// Markdown emphasis (`*` `_` `` ` ``) is stripped first so bold labels like
|
|
889
|
+
// `**Calories:**` are recognised. A prev with no colon never matches, so a
|
|
890
|
+
// plain soft-wrapped sentence is untouched.
|
|
891
|
+
const statStripped = prevTrimmed.replace(/[*_`]/g, '')
|
|
892
|
+
const colonIdx = statStripped.indexOf(':')
|
|
893
|
+
if (colonIdx > 0) {
|
|
894
|
+
const label = statStripped.slice(0, colonIdx).trim()
|
|
895
|
+
const value = statStripped.slice(colonIdx + 1)
|
|
896
|
+
const labelWords = label.length === 0 ? 0 : label.split(/\s+/).length
|
|
897
|
+
const isStatLine =
|
|
898
|
+
/^[ \t]+\S/.test(value) && labelWords >= 1 && labelWords <= 3 && label.length <= 24
|
|
899
|
+
if (isStatLine && !/^[a-z]/.test(nextTrimmed)) {
|
|
900
|
+
return true
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return false
|
|
415
904
|
}
|
|
416
905
|
|
|
417
906
|
/**
|
|
@@ -489,17 +978,59 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
|
|
|
489
978
|
|
|
490
979
|
if (cut <= 0) {
|
|
491
980
|
// Could not find a safe boundary below maxLen — the region is one
|
|
492
|
-
// indivisible block (e.g. a single huge fenced block
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
981
|
+
// indivisible block (e.g. a single huge fenced block or an unbreakable
|
|
982
|
+
// token run). Emitting the oversized remainder whole makes Telegram
|
|
983
|
+
// reject it (RICH_MESSAGE_TEXT_TOO_LONG) and drops the whole answer, so
|
|
984
|
+
// fall back to a raw character slice: every piece is guaranteed <= maxLen
|
|
985
|
+
// (a degraded-but-delivered message beats a hard reject). hardSliceToCap
|
|
986
|
+
// returns the head chunk plus the rest; keep looping on the remainder so
|
|
987
|
+
// any trailing splittable region still gets normal boundary treatment.
|
|
988
|
+
const sliced = hardSliceToCap(rest, maxLen)
|
|
989
|
+
chunks.push(stripBoundarySpacers(sliced[0], 'trailing'))
|
|
990
|
+
rest = stripBoundarySpacers(sliced.slice(1).join(''), 'leading')
|
|
991
|
+
continue
|
|
496
992
|
}
|
|
497
993
|
|
|
498
|
-
chunks.push(rest.slice(0, cut))
|
|
499
|
-
rest = rest.slice(cut)
|
|
994
|
+
chunks.push(stripBoundarySpacers(rest.slice(0, cut), 'trailing'))
|
|
995
|
+
rest = stripBoundarySpacers(rest.slice(cut), 'leading')
|
|
500
996
|
}
|
|
501
997
|
|
|
502
|
-
return chunks
|
|
998
|
+
return chunks.map((c) => stripBoundarySpacers(c, 'trailing'))
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Strip stray paragraph-spacer / blank lines off a chunk boundary so a cut that
|
|
1003
|
+
* lands inside an injected spacer gap (`\n\n${PARAGRAPH_SPACER}\n\n`, see
|
|
1004
|
+
* addParagraphSpacers) never leaves a continuation chunk that OPENS with a bare
|
|
1005
|
+
* U+00A0 spacer line, nor a prior chunk that ENDS with one.
|
|
1006
|
+
*
|
|
1007
|
+
* A "boundary blank run" is any sequence of newlines and spacer-only lines (a
|
|
1008
|
+
* line whose only content is the U+00A0 spacer, optionally surrounded by ASCII
|
|
1009
|
+
* spaces/tabs) in ANY interleaving — `\n \n`, ` \n\n`, `\n\n \n`, etc. The
|
|
1010
|
+
* legacy behaviour (strip leading ASCII `\n+` only) is a strict subset, so a
|
|
1011
|
+
* boundary with NO spacer is unaffected. Idempotent: a chunk already trimmed
|
|
1012
|
+
* has nothing left to strip.
|
|
1013
|
+
*
|
|
1014
|
+
* - `'leading'` → strip the run from the START (the continuation chunk).
|
|
1015
|
+
* - `'trailing'` → strip the run from the END (the just-emitted prior chunk).
|
|
1016
|
+
*/
|
|
1017
|
+
function stripBoundarySpacers(chunk: string, side: 'leading' | 'trailing'): string {
|
|
1018
|
+
// One blank-or-spacer line: optional ASCII ws, optional one U+00A0, optional
|
|
1019
|
+
// ASCII ws — i.e. a line that renders empty. A run of these (joined by \n,
|
|
1020
|
+
// with leading/trailing \n) is what we peel off the boundary.
|
|
1021
|
+
const sp = PARAGRAPH_SPACER
|
|
1022
|
+
if (side === 'leading') {
|
|
1023
|
+
// Leading: one-or-more newlines, optionally with spacer-only lines mixed in.
|
|
1024
|
+
return chunk.replace(
|
|
1025
|
+
new RegExp(`^(?:[ \\t]*${sp}?[ \\t]*\\n+)+`),
|
|
1026
|
+
'',
|
|
1027
|
+
)
|
|
1028
|
+
}
|
|
1029
|
+
// Trailing: a newline run, optionally with spacer-only lines, at the very end.
|
|
1030
|
+
return chunk.replace(
|
|
1031
|
+
new RegExp(`(?:\\n+[ \\t]*${sp}?[ \\t]*)+$`),
|
|
1032
|
+
'',
|
|
1033
|
+
)
|
|
503
1034
|
}
|
|
504
1035
|
|
|
505
1036
|
/**
|