switchroom 0.16.38 → 0.16.47
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 +8 -2
- package/dist/auth-broker/index.js +7 -1
- package/dist/cli/notion-write-pretool.mjs +7 -1
- package/dist/cli/switchroom.js +1259 -375
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +116 -84
- package/dist/vault/approvals/kernel-server.js +8 -2
- package/dist/vault/broker/server.js +8 -2
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +2 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +20 -2
- package/telegram-plugin/dist/gateway/gateway.js +2197 -964
- package/telegram-plugin/dist/server.js +20 -2
- package/telegram-plugin/format.ts +305 -31
- package/telegram-plugin/gateway/gateway.ts +310 -70
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/stream-reply-handler.ts +19 -1
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/card-format.test.ts +28 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -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/model-command.test.ts +213 -47
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/telegram-format.test.ts +33 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +20 -108
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +472 -22
- 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-ondemand.ts +161 -10
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/worker-activity-feed.ts +9 -1
|
@@ -17253,6 +17253,23 @@ function projectAssistantTextBlocks(content, make) {
|
|
|
17253
17253
|
});
|
|
17254
17254
|
return out;
|
|
17255
17255
|
}
|
|
17256
|
+
function assistantLineCarriesAnswerSurface(content) {
|
|
17257
|
+
if (!Array.isArray(content))
|
|
17258
|
+
return false;
|
|
17259
|
+
for (const c of content) {
|
|
17260
|
+
const ct = c?.type ?? "";
|
|
17261
|
+
if (ct === "text") {
|
|
17262
|
+
const t = c.text;
|
|
17263
|
+
if (typeof t === "string" && t.trim().length > 0)
|
|
17264
|
+
return true;
|
|
17265
|
+
} else if (ct === "tool_use") {
|
|
17266
|
+
const name = c.name ?? "";
|
|
17267
|
+
if (name !== "Agent" && name !== "Task")
|
|
17268
|
+
return true;
|
|
17269
|
+
}
|
|
17270
|
+
}
|
|
17271
|
+
return false;
|
|
17272
|
+
}
|
|
17256
17273
|
function projectTranscriptLine(line) {
|
|
17257
17274
|
if (line.length > MAX_JSONL_LINE_BYTES)
|
|
17258
17275
|
return [];
|
|
@@ -17414,7 +17431,7 @@ function projectSubagentLine(line, agentId, state) {
|
|
|
17414
17431
|
}
|
|
17415
17432
|
});
|
|
17416
17433
|
const stopReason = message?.stop_reason;
|
|
17417
|
-
if (stopReason === "end_turn") {
|
|
17434
|
+
if (stopReason === "end_turn" && assistantLineCarriesAnswerSurface(content)) {
|
|
17418
17435
|
events.push({ kind: "sub_agent_turn_end", agentId });
|
|
17419
17436
|
}
|
|
17420
17437
|
return events;
|
|
@@ -24677,7 +24694,8 @@ var init_bridge = __esm(async () => {
|
|
|
24677
24694
|
chat_id: { type: "string", description: "Chat to render the approval card in (use the chat_id of the user message that triggered the workflow)." },
|
|
24678
24695
|
key: { type: "string", description: "Vault key the agent wants access to (matches the key shown in the VAULT-BROKER-DENIED error, e.g. `fatsecret/credentials`)." },
|
|
24679
24696
|
scope: { type: "string", enum: ["read", "write"], description: 'Access scope: "read" (default) for `vault:<key>` references; "write" if the agent needs to put new values.' },
|
|
24680
|
-
reason: { type: "string", description:
|
|
24697
|
+
reason: { type: "string", description: 'REQUIRED in practice \u2014 short human-readable rationale rendered on the card (e.g. "to look up today\'s food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation \u2014 they will usually Deny. Always supply a one-line rationale. `why` is accepted as an alias (matching `vault_request_save`).' },
|
|
24698
|
+
why: { type: "string", description: "Alias for `reason` (matches the sibling tool `vault_request_save`). If both are supplied, `reason` wins." },
|
|
24681
24699
|
duration: { type: "string", description: 'Requested grant TTL, like "30d" or "12h". Default 30d, capped at 90d. Beyond 90d the operator should use the host CLI explicitly.' },
|
|
24682
24700
|
message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
|
|
24683
24701
|
},
|
|
@@ -266,10 +266,35 @@ export function normalizeParagraphBreaks(text: string): string {
|
|
|
266
266
|
// never touched. See splitCollapsedInlineBullets for the exact rule.
|
|
267
267
|
const masked = splitCollapsedInlineBullets(maskedRaw)
|
|
268
268
|
|
|
269
|
-
// Step 1: collapse
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
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')
|
|
273
298
|
|
|
274
299
|
// Step 2: walk lines and promote lone prose breaks. We rebuild the string by
|
|
275
300
|
// joining lines with the right separator. A separator is "hard" (` \n`) only
|
|
@@ -348,13 +373,14 @@ export const PARAGRAPH_SPACER = ' '
|
|
|
348
373
|
* the pre-#2669 HTML behaviour). See PARAGRAPH_SPACER for why a U+00A0 line is
|
|
349
374
|
* the reliable trick.
|
|
350
375
|
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
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.
|
|
358
384
|
*
|
|
359
385
|
* Runs on code-masked text (so a blank line inside a fenced block is never
|
|
360
386
|
* touched) and is idempotent — a gap that already contains a U+00A0 spacer
|
|
@@ -385,15 +411,45 @@ export function addParagraphSpacers(text: string): string {
|
|
|
385
411
|
// Trim ASCII-only (preserve U+00A0) so the spacer line is recognisable.
|
|
386
412
|
const asciiTrim = (line: string): string => line.replace(/^[ \t\r\f\v]+|[ \t\r\f\v]+$/g, '')
|
|
387
413
|
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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.
|
|
397
453
|
return true
|
|
398
454
|
}
|
|
399
455
|
|
|
@@ -426,8 +482,7 @@ export function addParagraphSpacers(text: string): string {
|
|
|
426
482
|
!alreadySpaced &&
|
|
427
483
|
above != null &&
|
|
428
484
|
below != null &&
|
|
429
|
-
|
|
430
|
-
isProseLine(below)
|
|
485
|
+
shouldSpaceGap(above, below)
|
|
431
486
|
) {
|
|
432
487
|
// Emit: blank, spacer paragraph, blank — a U+00A0 paragraph wedged
|
|
433
488
|
// between two real blank lines so CommonMark renders it as a visible
|
|
@@ -469,6 +524,154 @@ function nextNonBlank(
|
|
|
469
524
|
return null
|
|
470
525
|
}
|
|
471
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
|
+
|
|
472
675
|
// ---------------------------------------------------------------------------
|
|
473
676
|
// Block-boundary blank-line guarantee (Step 3 of normalizeParagraphBreaks)
|
|
474
677
|
// ---------------------------------------------------------------------------
|
|
@@ -558,8 +761,34 @@ function ensureBlockBoundaries(text: string, placeholder?: string): string {
|
|
|
558
761
|
const startsFence = isFenceOpenLine(line, placeholder) && !isFenceOpenLine(prev, placeholder)
|
|
559
762
|
const startsQuote = isBlockquoteLine(line) && !isBlockquoteLine(prev)
|
|
560
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)
|
|
770
|
+
|
|
771
|
+
if (startsTableHere || startsFence || startsQuote || startsHeading || startsList) {
|
|
772
|
+
result.push('')
|
|
773
|
+
}
|
|
774
|
+
}
|
|
561
775
|
|
|
562
|
-
|
|
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) {
|
|
563
792
|
result.push('')
|
|
564
793
|
}
|
|
565
794
|
}
|
|
@@ -604,9 +833,13 @@ function isMarkerLine(line: string, placeholder?: string): boolean {
|
|
|
604
833
|
t.startsWith('>') ||
|
|
605
834
|
// ATX heading.
|
|
606
835
|
/^#{1,6}\s/.test(t) ||
|
|
607
|
-
// Table row (leading pipe) or table
|
|
608
|
-
|
|
609
|
-
|
|
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) ||
|
|
610
843
|
// Fenced code delimiter (defensive — fences are masked, but a lone/odd
|
|
611
844
|
// fence line can survive masking).
|
|
612
845
|
t.startsWith('```') ||
|
|
@@ -633,7 +866,41 @@ function shouldPromoteBreak(prev: string, next: string, placeholder?: string): b
|
|
|
633
866
|
// terminator (e.g. `He said "go."` or `(done.)`).
|
|
634
867
|
const unwrapped = prevTrimmed.replace(/[)"'’”\]]+$/, '')
|
|
635
868
|
const terminator = unwrapped.slice(-1)
|
|
636
|
-
|
|
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
|
|
637
904
|
}
|
|
638
905
|
|
|
639
906
|
/**
|
|
@@ -711,10 +978,17 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
|
|
|
711
978
|
|
|
712
979
|
if (cut <= 0) {
|
|
713
980
|
// Could not find a safe boundary below maxLen — the region is one
|
|
714
|
-
// indivisible block (e.g. a single huge fenced block
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
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
|
|
718
992
|
}
|
|
719
993
|
|
|
720
994
|
chunks.push(stripBoundarySpacers(rest.slice(0, cut), 'trailing'))
|