switchroom 0.18.18 → 0.18.20
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/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +36 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1131 -285
- package/telegram-plugin/dist/server.js +6 -0
- package/telegram-plugin/format.ts +208 -125
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +800 -107
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/llm-error-present.ts +68 -30
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/subagent-watcher.ts +244 -81
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +39 -4
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
- package/telegram-plugin/tests/telegram-format.test.ts +72 -4
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +2 -1
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -16994,6 +16994,10 @@ var init_plugin_logger = __esm(() => {
|
|
|
16994
16994
|
DEFAULT_LOG_PATH2 = join2(homedir2(), ".switchroom", "logs", "telegram-plugin.log");
|
|
16995
16995
|
ROTATE_AT_BYTES2 = 50 * 1024 * 1024;
|
|
16996
16996
|
});
|
|
16997
|
+
|
|
16998
|
+
// format.ts
|
|
16999
|
+
var init_format = () => {};
|
|
17000
|
+
|
|
16997
17001
|
// raw-error-scrub.ts
|
|
16998
17002
|
function extractRequestId(raw) {
|
|
16999
17003
|
if (typeof raw !== "string" || raw.length === 0)
|
|
@@ -17079,6 +17083,7 @@ function getNestedObj(obj, key) {
|
|
|
17079
17083
|
}
|
|
17080
17084
|
var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS, cooldownMap;
|
|
17081
17085
|
var init_operator_events = __esm(() => {
|
|
17086
|
+
init_format();
|
|
17082
17087
|
DEFAULT_OPERATOR_EVENT_COOLDOWN_MS = 5 * 60000;
|
|
17083
17088
|
cooldownMap = new Map;
|
|
17084
17089
|
});
|
|
@@ -17098,6 +17103,7 @@ var init_text_voice_scrub = __esm(() => {
|
|
|
17098
17103
|
|
|
17099
17104
|
// card-format.ts
|
|
17100
17105
|
var init_card_format = __esm(() => {
|
|
17106
|
+
init_format();
|
|
17101
17107
|
init_text_voice_scrub();
|
|
17102
17108
|
});
|
|
17103
17109
|
|
|
@@ -111,6 +111,16 @@ export function escapeLinkHref(href: string): string {
|
|
|
111
111
|
* the old whole-message heuristic ("bail if any real newline exists") was
|
|
112
112
|
* too broad and prevented repair of mixed messages that had both real
|
|
113
113
|
* newlines and stray literal `\n` escape sequences outside code spans.
|
|
114
|
+
*
|
|
115
|
+
* KNOWN ISSUE (accepted tradeoff): a literal backslash sequence `\n`/`\r`/`\t`
|
|
116
|
+
* that a user genuinely meant as text OUTSIDE a code span is unescaped into a
|
|
117
|
+
* real newline/tab — e.g. a bare Windows path `C:\new\table` becomes
|
|
118
|
+
* `C:<newline>ew<tab>able`. This is deliberately not guarded: the fleet is
|
|
119
|
+
* Linux-only, such paths in prose (rather than inside a `code span`, which is
|
|
120
|
+
* masked and safe) are vanishingly rare, and the far commoner failure this
|
|
121
|
+
* repairs is an LLM emitting JSON-escaped `\n\n` as literal text. If a
|
|
122
|
+
* Windows-path use case ever matters, restrict the unescape to sequences not
|
|
123
|
+
* flanked by path-like characters rather than removing it.
|
|
114
124
|
*/
|
|
115
125
|
export function repairEscapedWhitespace(text: string): string {
|
|
116
126
|
if (!/\\[nrt"\\]/.test(text)) return text
|
|
@@ -315,13 +325,14 @@ export function normalizeParagraphBreaks(text: string): string {
|
|
|
315
325
|
// as an oversized / ragged gap in the raw text and in some clients, and
|
|
316
326
|
// it was the "stray blank line" seen in real replies. Collapse it.
|
|
317
327
|
//
|
|
318
|
-
// Deliberately ASCII-only:
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
328
|
+
// Deliberately ASCII-only: the `[ \t\r]` character class excludes U+00A0 by
|
|
329
|
+
// construction, so a line whose only content is a non-breaking space a user
|
|
330
|
+
// legitimately typed is left intact rather than silently collapsed. (This
|
|
331
|
+
// used to also protect the NBSP paragraph spacer that addParagraphSpacers
|
|
332
|
+
// injected downstream; that spacer pass was removed in the #2669 follow-up —
|
|
333
|
+
// paragraph gaps now rely on plain `\n\n` — but keeping this ASCII-only is
|
|
334
|
+
// still the conservative choice.) Runs on code-masked text, so a blank-ish
|
|
335
|
+
// line inside a fenced block is parked and never touched.
|
|
325
336
|
let out = masked
|
|
326
337
|
// Collapse any run of newlines interleaved with ASCII whitespace-only
|
|
327
338
|
// interior lines down to a single clean `\n\n`. Requires at least one
|
|
@@ -476,18 +487,24 @@ export function hardenCardBreaks(text: string): string {
|
|
|
476
487
|
* The non-collapsible spacer paragraph injected between two prose paragraphs.
|
|
477
488
|
*
|
|
478
489
|
* Telegram's Bot API 10.1 rich-message renderer (the GFM/CommonMark engine
|
|
479
|
-
* behind `sendRichMessage` / `editMessageText({ markdown })
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
490
|
+
* behind `sendRichMessage` / `editMessageText({ markdown })`, and the in-repo
|
|
491
|
+
* IR renderer in `render/` that feeds it) renders a `\n\n` paragraph break
|
|
492
|
+
* TIGHT — the two paragraphs sit on adjacent lines with no visible empty line
|
|
493
|
+
* between them (live-confirmed: an outbound message with real `\n\n` gaps
|
|
494
|
+
* renders jammed; only list/table BLOCK boundaries produce a visible gap). The
|
|
495
|
+
* legacy markdown→HTML path (removed in #2669) sent `\n\n` literally with
|
|
496
|
+
* `parse_mode:"HTML"`, where two newlines render as a real blank line. That
|
|
497
|
+
* regression is the operator-confirmed "paragraphs jammed together" symptom
|
|
498
|
+
* (#2692).
|
|
485
499
|
*
|
|
486
500
|
* CommonMark discards blank lines made of ASCII whitespace, but a line whose
|
|
487
501
|
* only content is a NON-breaking space (U+00A0) is a genuine, non-empty
|
|
488
|
-
* paragraph — it renders as a visible empty line. So `A\n\n
|
|
489
|
-
*
|
|
490
|
-
*
|
|
502
|
+
* paragraph — it renders as a visible empty line. So `A\n\n \n\nB` renders as
|
|
503
|
+
* three paragraphs: A, a blank-looking line, then B — the visible gap the HTML
|
|
504
|
+
* path used to produce. This survives the in-repo IR renderer's re-parse /
|
|
505
|
+
* re-render (verified: a U+00A0-only line round-trips intact, while an
|
|
506
|
+
* ASCII-space-only line is collapsed to a tight `\n\n`), so it reaches the wire
|
|
507
|
+
* whether `SWITCHROOM_RICH_RENDER` is on (default) or off.
|
|
491
508
|
*/
|
|
492
509
|
export const PARAGRAPH_SPACER = ' '
|
|
493
510
|
|
|
@@ -507,14 +524,23 @@ export const PARAGRAPH_SPACER = ' '
|
|
|
507
524
|
* are never gaps at all and are untouched by construction.
|
|
508
525
|
*
|
|
509
526
|
* Runs on code-masked text (so a blank line inside a fenced block is never
|
|
510
|
-
* touched)
|
|
511
|
-
*
|
|
527
|
+
* touched).
|
|
528
|
+
*
|
|
529
|
+
* IDEMPOTENT & DOUBLE-GAP-PROOF (the #3208 regression this restores fixes):
|
|
530
|
+
* each inter-block gap — whatever it originally holds (a bare `\n\n`, a
|
|
531
|
+
* pre-existing U+00A0 spacer `\n\n \n\n`, a stray extra blank line `\n\n\n`, an
|
|
532
|
+
* ASCII-space-only line) — is CANONICALISED to exactly one form: `\n\n`
|
|
533
|
+
* (tight) or `\n\n${PARAGRAPH_SPACER}\n\n` (spaced). It therefore inserts
|
|
534
|
+
* EXACTLY ONE spacer per spaced gap and can never stack a second, and running
|
|
535
|
+
* the pass twice yields the same output as running it once. #3208 removed the
|
|
536
|
+
* whole mechanism to kill a double-gap; the correct fix was to make the gap
|
|
537
|
+
* canonical (this), not to delete the spacer and reintroduce the jammed-
|
|
538
|
+
* paragraph symptom fleet-wide.
|
|
512
539
|
*
|
|
513
|
-
* Intended to run in the outbound send path AFTER normalizeParagraphBreaks
|
|
514
|
-
* which has already collapsed 3+ newline runs to `\n\n
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
* contract is unchanged.
|
|
540
|
+
* Intended to run in the outbound send path AFTER normalizeParagraphBreaks
|
|
541
|
+
* (which has already collapsed 3+ newline runs to `\n\n` and guaranteed
|
|
542
|
+
* block-boundary blank lines) — but the canonicalisation above means it is
|
|
543
|
+
* safe on un-normalized text too (e.g. the edit path).
|
|
518
544
|
*/
|
|
519
545
|
export function addParagraphSpacers(text: string): string {
|
|
520
546
|
if (!text.includes('\n\n')) return text
|
|
@@ -524,28 +550,31 @@ export function addParagraphSpacers(text: string): string {
|
|
|
524
550
|
|
|
525
551
|
if (!masked.includes('\n\n')) return restore(masked)
|
|
526
552
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
//
|
|
546
|
-
|
|
553
|
+
const SP = PARAGRAPH_SPACER
|
|
554
|
+
|
|
555
|
+
// ASCII-only trim (preserve U+00A0): `String.prototype.trim()` strips U+00A0,
|
|
556
|
+
// so a spacer line would read as "blank"; trimming ASCII whitespace only
|
|
557
|
+
// keeps the spacer line recognisable as content.
|
|
558
|
+
const asciiTrim = (line: string): string =>
|
|
559
|
+
line.replace(/^[ \t\r\f\v]+|[ \t\r\f\v]+$/g, '')
|
|
560
|
+
|
|
561
|
+
// "Blank-ish" = a line that renders as an empty paragraph: ASCII-whitespace-
|
|
562
|
+
// only, OR a line whose only non-ASCII-whitespace content is the U+00A0
|
|
563
|
+
// spacer. Coalescing BOTH kinds into one gap is what makes the pass
|
|
564
|
+
// idempotent and double-gap-proof — a pre-existing spacer or extra blank line
|
|
565
|
+
// is absorbed into the gap and re-emitted canonically, never stacked.
|
|
566
|
+
const isBlankish = (line: string): boolean => {
|
|
567
|
+
const t = asciiTrim(line)
|
|
568
|
+
return t === '' || t === SP
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Classify the block kind of a facing content line so the spacer decision can
|
|
572
|
+
// be made per BLOCK TRANSITION (#uniform-block-spacing). A spacer is inserted
|
|
573
|
+
// at every gap between two DIFFERENT block kinds and between two prose
|
|
574
|
+
// paragraphs, but NEVER inside a single block's interior (two items of the
|
|
575
|
+
// same loose list, two rows of a table, consecutive quotes/fences/dividers).
|
|
576
|
+
type BlockKind = 'list' | 'table' | 'quote' | 'heading' | 'fence' | 'divider' | 'prose'
|
|
547
577
|
const blockKind = (line: string): BlockKind => {
|
|
548
|
-
if (asciiTrim(line) === spacerLine) return 'spacer'
|
|
549
578
|
if (isFenceOpenLine(line, placeholder)) return 'fence'
|
|
550
579
|
if (isListItemLine(line)) return 'list'
|
|
551
580
|
if (isTableRowLine(line) || isTableDelimiterLine(line)) return 'table'
|
|
@@ -555,8 +584,7 @@ export function addParagraphSpacers(text: string): string {
|
|
|
555
584
|
return 'prose'
|
|
556
585
|
}
|
|
557
586
|
|
|
558
|
-
// Same-kind structural pairs whose
|
|
559
|
-
// list's item gap, consecutive tables/quotes/fences) — no spacer there.
|
|
587
|
+
// Same-kind structural pairs whose gap is a block INTERIOR — no spacer there.
|
|
560
588
|
const SAME_KIND_TIGHT: ReadonlySet<BlockKind> = new Set([
|
|
561
589
|
'list',
|
|
562
590
|
'table',
|
|
@@ -568,86 +596,52 @@ export function addParagraphSpacers(text: string): string {
|
|
|
568
596
|
const shouldSpaceGap = (above: string, below: string): boolean => {
|
|
569
597
|
const a = blockKind(above)
|
|
570
598
|
const b = blockKind(below)
|
|
571
|
-
// A facing spacer line means the gap is already spaced (idempotency —
|
|
572
|
-
// also guarded by alreadySpaced at the call site).
|
|
573
|
-
if (a === 'spacer' || b === 'spacer') return false
|
|
574
599
|
if (a === b && SAME_KIND_TIGHT.has(a)) return false
|
|
575
600
|
// Everything else is a genuine block transition (incl. prose→prose,
|
|
576
601
|
// heading→anything, list↔paragraph, table/quote boundaries) — space it.
|
|
577
602
|
return true
|
|
578
603
|
}
|
|
579
604
|
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
605
|
+
// Tokenize into CONTENT runs (consecutive non-blank-ish lines, joined by a
|
|
606
|
+
// single `\n` — soft breaks stay inside a content block) and GAP runs
|
|
607
|
+
// (consecutive blank-ish lines). Tokens strictly alternate by construction.
|
|
608
|
+
type Tok = { kind: 'content' | 'gap'; lines: string[] }
|
|
609
|
+
const toks: Tok[] = []
|
|
610
|
+
for (const line of masked.split('\n')) {
|
|
611
|
+
const kind = isBlankish(line) ? 'gap' : 'content'
|
|
612
|
+
const last = toks[toks.length - 1]
|
|
613
|
+
if (last && last.kind === kind) last.lines.push(line)
|
|
614
|
+
else toks.push({ kind, lines: [line] })
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Rebuild. A CONTENT token re-emits its lines verbatim. An INTER-content GAP
|
|
618
|
+
// (a gap flanked by content on BOTH sides) is re-emitted in canonical form —
|
|
619
|
+
// one blank line (`''` → `\n\n`) for a tight gap, or `['', SP, '']`
|
|
620
|
+
// (→ `\n\n${SP}\n\n`) for a spaced gap — discarding whatever it held. A
|
|
621
|
+
// leading/trailing gap (no content on one side, only possible before the
|
|
622
|
+
// first / after the last content block) is preserved verbatim.
|
|
589
623
|
const out: string[] = []
|
|
590
|
-
for (let i = 0; i <
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
if (
|
|
606
|
-
!alreadySpaced &&
|
|
607
|
-
above != null &&
|
|
608
|
-
below != null &&
|
|
609
|
-
shouldSpaceGap(above, below)
|
|
610
|
-
) {
|
|
611
|
-
// Emit: blank, spacer paragraph, blank — a U+00A0 paragraph wedged
|
|
612
|
-
// between two real blank lines so CommonMark renders it as a visible
|
|
613
|
-
// empty line between the two prose paragraphs.
|
|
614
|
-
out.push('')
|
|
615
|
-
out.push(spacerLine)
|
|
616
|
-
out.push('')
|
|
617
|
-
continue
|
|
618
|
-
}
|
|
619
|
-
}
|
|
624
|
+
for (let i = 0; i < toks.length; i++) {
|
|
625
|
+
const tok = toks[i]
|
|
626
|
+
if (tok.kind === 'content') {
|
|
627
|
+
out.push(...tok.lines)
|
|
628
|
+
continue
|
|
629
|
+
}
|
|
630
|
+
const prev = toks[i - 1]
|
|
631
|
+
const next = toks[i + 1]
|
|
632
|
+
if (prev?.kind === 'content' && next?.kind === 'content') {
|
|
633
|
+
const above = prev.lines[prev.lines.length - 1]
|
|
634
|
+
const below = next.lines[0]
|
|
635
|
+
if (shouldSpaceGap(above, below)) out.push('', SP, '')
|
|
636
|
+
else out.push('')
|
|
637
|
+
} else {
|
|
638
|
+
out.push(...tok.lines)
|
|
620
639
|
}
|
|
621
|
-
out.push(line)
|
|
622
640
|
}
|
|
623
641
|
|
|
624
642
|
return restore(out.join('\n'))
|
|
625
643
|
}
|
|
626
644
|
|
|
627
|
-
/**
|
|
628
|
-
* Last non-blank entry already emitted into `arr`, or null. `isBlank` is the
|
|
629
|
-
* caller's blank test (ASCII-only, so a U+00A0 spacer line counts as
|
|
630
|
-
* non-blank — `String.trim()` would wrongly strip it).
|
|
631
|
-
*/
|
|
632
|
-
function lastNonBlank(arr: string[], isBlank: (s: string) => boolean): string | null {
|
|
633
|
-
for (let i = arr.length - 1; i >= 0; i--) {
|
|
634
|
-
if (!isBlank(arr[i])) return arr[i]
|
|
635
|
-
}
|
|
636
|
-
return null
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/** First non-blank line at or after index `from` in `lines`, or null. */
|
|
640
|
-
function nextNonBlank(
|
|
641
|
-
lines: string[],
|
|
642
|
-
from: number,
|
|
643
|
-
isBlank: (s: string) => boolean,
|
|
644
|
-
): string | null {
|
|
645
|
-
for (let i = from; i < lines.length; i++) {
|
|
646
|
-
if (!isBlank(lines[i])) return lines[i]
|
|
647
|
-
}
|
|
648
|
-
return null
|
|
649
|
-
}
|
|
650
|
-
|
|
651
645
|
// ---------------------------------------------------------------------------
|
|
652
646
|
// Punctuation / bullet normalization — fleet-wide consistent typography
|
|
653
647
|
// ---------------------------------------------------------------------------
|
|
@@ -678,7 +672,41 @@ export function normalizePunctuation(text: string): string {
|
|
|
678
672
|
const nonce = Math.random().toString(36).slice(2)
|
|
679
673
|
const { masked, restore } = maskCodeRegions(text, nonce)
|
|
680
674
|
|
|
681
|
-
|
|
675
|
+
// Mask inline-link DESTINATIONS `](href)` before the dash passes so a dash
|
|
676
|
+
// inside a URL is never rewritten. maskCodeRegions only masks code spans/
|
|
677
|
+
// fences, not link hrefs, so without this an en-dash in a path becomes a
|
|
678
|
+
// silently-wrong URL, and an em-dash becomes `, ` — the injected space
|
|
679
|
+
// TERMINATES the markdown link and leaks the trailing text as prose
|
|
680
|
+
// (`[a](https://x/foo—bar)` → `[a](https://x/foo, bar)`). Only the href
|
|
681
|
+
// inside the parens is masked; the visible link LABEL still normalizes like
|
|
682
|
+
// ordinary prose (dashes in label text are intended, per existing behaviour).
|
|
683
|
+
const linkMasks: string[] = []
|
|
684
|
+
const LINK_MASK_PH = `\x00RML${nonce}_`
|
|
685
|
+
const maskedLinks = masked.replace(/(\]\()([^)\n]*)(\))/g, (_m, open: string, href: string, close: string) => {
|
|
686
|
+
const idx = linkMasks.length
|
|
687
|
+
linkMasks.push(href)
|
|
688
|
+
return `${open}${LINK_MASK_PH}${idx}\x00${close}`
|
|
689
|
+
})
|
|
690
|
+
// Also protect GFM ANGLE-BRACKET AUTOLINK destinations `<scheme:…>` for the
|
|
691
|
+
// same reason (`<https://x/foo–bar>` → the en-dash would be rewritten to `-`,
|
|
692
|
+
// corrupting the URL). Only the URI inside the brackets is masked; the `<`/`>`
|
|
693
|
+
// are untouched. Conservative — requires a scheme-like `xxx:` prefix and no
|
|
694
|
+
// whitespace/`>` in the body (loosely mirrors the GFM absolute-URI autolink
|
|
695
|
+
// rule), so arbitrary `<…>` prose is never masked.
|
|
696
|
+
const maskedAutolinks = maskedLinks.replace(
|
|
697
|
+
/(<)([a-zA-Z][a-zA-Z0-9+.-]*:[^>\s]*)(>)/g,
|
|
698
|
+
(_m, lt: string, uri: string, gt: string) => {
|
|
699
|
+
const idx = linkMasks.length
|
|
700
|
+
linkMasks.push(uri)
|
|
701
|
+
return `${lt}${LINK_MASK_PH}${idx}\x00${gt}`
|
|
702
|
+
},
|
|
703
|
+
)
|
|
704
|
+
const escNonce = nonce.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
705
|
+
const linkRestoreRe = new RegExp(`\x00RML${escNonce}_(\\d+)\x00`, 'g')
|
|
706
|
+
const restoreLinks = (s: string): string =>
|
|
707
|
+
s.replace(linkRestoreRe, (_m, idx: string) => linkMasks[Number(idx)] ?? _m)
|
|
708
|
+
|
|
709
|
+
let out = maskedAutolinks
|
|
682
710
|
// 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
|
|
683
711
|
// flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
|
|
684
712
|
// dashes ("a — b — c") all normalize in one pass — a consumed \S would
|
|
@@ -694,6 +722,10 @@ export function normalizePunctuation(text: string): string {
|
|
|
694
722
|
// 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
|
|
695
723
|
.replace(/(\w)–(?=\w)/g, '$1-')
|
|
696
724
|
|
|
725
|
+
// Restore link hrefs now that the dash passes are done — before the bullet
|
|
726
|
+
// pass and the code restore.
|
|
727
|
+
out = restoreLinks(out)
|
|
728
|
+
|
|
697
729
|
// 4. Leading unicode bullet markers → GFM `- ` (per line, indent kept).
|
|
698
730
|
out = out
|
|
699
731
|
.split('\n')
|
|
@@ -884,9 +916,10 @@ function ensureBlockBoundaries(text: string, placeholder?: string): string {
|
|
|
884
916
|
const startsHeading = isHeadingLine(line) && !isHeadingLine(prev)
|
|
885
917
|
// List start glued to prose above by a single `\n` (uniform-block-
|
|
886
918
|
// spacing): a `- ` line already interrupts a paragraph in CommonMark,
|
|
887
|
-
// so the blank line is render-safe — it
|
|
888
|
-
//
|
|
889
|
-
// item) so list interiors stay
|
|
919
|
+
// so the blank line is render-safe — it makes the prose→list transition
|
|
920
|
+
// a real `\n\n` block boundary the GFM renderer honours. Never fires
|
|
921
|
+
// between two list items (prev is a list item) so list interiors stay
|
|
922
|
+
// tight.
|
|
890
923
|
const startsList = isListItemLine(line) && !isListItemLine(prev)
|
|
891
924
|
|
|
892
925
|
if (startsTableHere || startsFence || startsQuote || startsHeading || startsList) {
|
|
@@ -1096,6 +1129,12 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
|
|
|
1096
1129
|
cut = backOffOpenFence(rest, cut)
|
|
1097
1130
|
// Back off so the cut doesn't bisect a table row (a line with `|`).
|
|
1098
1131
|
cut = backOffTableRow(rest, cut)
|
|
1132
|
+
// Back off so the cut doesn't bisect an inline entity (`**bold**`,
|
|
1133
|
+
// `` `code` ``, `_italic_`, `[label](href)`), which would leave an unclosed
|
|
1134
|
+
// delimiter in the emitted chunk (Telegram then parse-rejects it to
|
|
1135
|
+
// plaintext, or mis-renders the continuation). Runs LAST so it also cleans
|
|
1136
|
+
// up a boundary the fence/table back-offs landed on.
|
|
1137
|
+
cut = backOffOpenInline(rest, cut)
|
|
1099
1138
|
|
|
1100
1139
|
if (cut <= 0) {
|
|
1101
1140
|
// Could not find a safe boundary below maxLen — the region is one
|
|
@@ -1142,16 +1181,10 @@ function stripBoundarySpacers(chunk: string, side: 'leading' | 'trailing'): stri
|
|
|
1142
1181
|
const sp = PARAGRAPH_SPACER
|
|
1143
1182
|
if (side === 'leading') {
|
|
1144
1183
|
// Leading: one-or-more newlines, optionally with spacer-only lines mixed in.
|
|
1145
|
-
return chunk.replace(
|
|
1146
|
-
new RegExp(`^(?:[ \\t]*${sp}?[ \\t]*\\n+)+`),
|
|
1147
|
-
'',
|
|
1148
|
-
)
|
|
1184
|
+
return chunk.replace(new RegExp(`^(?:[ \\t]*${sp}?[ \\t]*\\n+)+`), '')
|
|
1149
1185
|
}
|
|
1150
1186
|
// Trailing: a newline run, optionally with spacer-only lines, at the very end.
|
|
1151
|
-
return chunk.replace(
|
|
1152
|
-
new RegExp(`(?:\\n+[ \\t]*${sp}?[ \\t]*)+$`),
|
|
1153
|
-
'',
|
|
1154
|
-
)
|
|
1187
|
+
return chunk.replace(new RegExp(`(?:\\n+[ \\t]*${sp}?[ \\t]*)+$`), '')
|
|
1155
1188
|
}
|
|
1156
1189
|
|
|
1157
1190
|
/**
|
|
@@ -1190,3 +1223,53 @@ function backOffTableRow(text: string, cut: number): number {
|
|
|
1190
1223
|
}
|
|
1191
1224
|
return cut
|
|
1192
1225
|
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Inline entities that must not be bisected by a chunk cut. Each pattern is
|
|
1229
|
+
* matched over the full `text`; if the chosen `cut` lands STRICTLY inside a
|
|
1230
|
+
* matched span, retreat to that span's start so the whole span moves to the
|
|
1231
|
+
* next chunk (mirrors backOffOpenFence / backOffTableRow). Cutting inside a
|
|
1232
|
+
* span would strand an unclosed `***`/`**`/`*` / `` ` `` / `___`/`__`/`_` /
|
|
1233
|
+
* `](` delimiter, which Telegram parse-rejects to plaintext or mis-renders
|
|
1234
|
+
* across the boundary.
|
|
1235
|
+
*
|
|
1236
|
+
* The TRIPLE-marker patterns (`***bold-italic***` / `___…___`) come FIRST so
|
|
1237
|
+
* their whole span wins the earliest-start back-off in backOffOpenInline: the
|
|
1238
|
+
* double-marker pattern would otherwise match the inner `**…**` of a `***…***`
|
|
1239
|
+
* span and retreat only past that, stranding the lone outer `*` (odd asterisk
|
|
1240
|
+
* count → the italic is lost).
|
|
1241
|
+
*
|
|
1242
|
+
* The `_italic_` pattern is boundary-guarded so snake_case identifiers
|
|
1243
|
+
* (`foo_bar_baz`) don't read as emphasis; a stray match there is harmless
|
|
1244
|
+
* anyway (it only shifts the cut to a `_` character, still a clean boundary).
|
|
1245
|
+
*/
|
|
1246
|
+
const INLINE_SPAN_PATTERNS: readonly RegExp[] = [
|
|
1247
|
+
/`[^`\n]+`/g, // inline code
|
|
1248
|
+
/\*\*\*[^*\n]+\*\*\*/g, // bold-italic (triple) — before the bold pattern
|
|
1249
|
+
/___[^_\n]+___/g, // bold-italic underscore (triple)
|
|
1250
|
+
/\*\*[^*\n]+\*\*/g, // bold
|
|
1251
|
+
/__[^_\n]+__/g, // underline
|
|
1252
|
+
/(?<![\w*])_[^_\n]+_(?![\w*])/g, // italic (snake_case-guarded)
|
|
1253
|
+
/\[[^\]\n]*\]\([^)\n]*\)/g, // link [label](href)
|
|
1254
|
+
]
|
|
1255
|
+
|
|
1256
|
+
function backOffOpenInline(text: string, cut: number): number {
|
|
1257
|
+
if (cut <= 0 || cut >= text.length) return cut
|
|
1258
|
+
let earliest = cut
|
|
1259
|
+
for (const re of INLINE_SPAN_PATTERNS) {
|
|
1260
|
+
re.lastIndex = 0
|
|
1261
|
+
let m: RegExpExecArray | null
|
|
1262
|
+
while ((m = re.exec(text)) !== null) {
|
|
1263
|
+
const start = m.index
|
|
1264
|
+
const end = start + m[0].length
|
|
1265
|
+
// Cut strictly inside this span → the span straddles the boundary.
|
|
1266
|
+
if (start < cut && cut < end && start < earliest) earliest = start
|
|
1267
|
+
// Matches arrive in order; once a span starts at/after the cut, no later
|
|
1268
|
+
// span can contain it.
|
|
1269
|
+
if (start >= cut) break
|
|
1270
|
+
// Guard against a zero-width match wedging the loop.
|
|
1271
|
+
if (re.lastIndex === start) re.lastIndex = start + 1
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return earliest
|
|
1275
|
+
}
|
|
@@ -35,6 +35,38 @@ export function baseAgent(name: string): string {
|
|
|
35
35
|
return isCronIdentity(name) ? name.slice(0, -CRON_IDENTITY_SUFFIX.length) : name;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* True iff an inject_inbound fire is a scheduled cron fire — Tier-1 cheap-cron
|
|
40
|
+
* (`meta.session='cron'`, routed to the derived `<agent>-cron` bridge) OR a
|
|
41
|
+
* Tier-2 full-session cron (`meta.source='cron'`, lands on the main bridge).
|
|
42
|
+
*
|
|
43
|
+
* #3114 — such a fire must NOT stamp the MAIN session's idle-clear clock at
|
|
44
|
+
* inject time. Before #3113, `onInjectInbound` stamped unconditionally so a
|
|
45
|
+
* "working scheduled agent isn't wiped after 3h of no inbound". That is now
|
|
46
|
+
* redundant AND harmful: a cron cadence shorter than `idle_clear_after`
|
|
47
|
+
* re-arms the timer on every fire and keeps idle-clear permanently suppressed
|
|
48
|
+
* for that agent. After #3113 a cron fire that does REAL work already stamps
|
|
49
|
+
* the main clock through `handleSessionEvent` on every genuine session event,
|
|
50
|
+
* so the blanket inject-time stamp buys nothing for main-bridge fires — and a
|
|
51
|
+
* cheap-cron fire (whose session events are dropped for the cron identity in
|
|
52
|
+
* `onSessionEvent`) correctly stops warming the main clock once it is gone.
|
|
53
|
+
*
|
|
54
|
+
* Only cron fires are gated: other synthetic-source injects (reaction, vault
|
|
55
|
+
* grant, resume) reflect genuine operator/session presence and still stamp.
|
|
56
|
+
*
|
|
57
|
+
* DOCUMENTED RESIDUAL (#3114, operator-approved): a Tier-2 cron pinned to the
|
|
58
|
+
* MAIN session (`context:'agent'`) that replies NO_REPLY still runs a real
|
|
59
|
+
* turn on the main bridge, which emits session events → stamps via
|
|
60
|
+
* `handleSessionEvent`. So "a NO_REPLY poll isn't presence" is fully closed
|
|
61
|
+
* only for cheap/derived-bridge crons; an expensive main-session poll still
|
|
62
|
+
* warms the clock because the model genuinely ran. This predicate governs only
|
|
63
|
+
* the inject-time stamp, not the session-event stamp — closing the main-
|
|
64
|
+
* session-poll case would need a separate weaker clock, out of scope here.
|
|
65
|
+
*/
|
|
66
|
+
export function isCronInjectFire(meta: Record<string, string> | undefined): boolean {
|
|
67
|
+
return meta?.source === "cron" || meta?.session === "cron";
|
|
68
|
+
}
|
|
69
|
+
|
|
38
70
|
/**
|
|
39
71
|
* Resolve the IPC routing target for an inject_inbound. When the fire
|
|
40
72
|
* carries `meta.session='cron'` it goes to the derived cron bridge; every
|