switchroom 0.21.8 → 0.21.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +100 -39
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/skills/switchroom-architecture/telegram.md +12 -10
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/README.md +3 -1
- package/telegram-plugin/dist/gateway/gateway.js +919 -348
- package/telegram-plugin/format.ts +30 -5
- package/telegram-plugin/gateway/outbound-send-path.ts +7 -0
- package/telegram-plugin/gateway/speech-capture.ts +158 -0
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/render/code-segments.ts +38 -4
- package/telegram-plugin/render/dollar-math-guard.ts +16 -1
- package/telegram-plugin/render/html-fold.ts +354 -0
- package/telegram-plugin/render/ir.ts +53 -3
- package/telegram-plugin/render/parse.ts +642 -42
- package/telegram-plugin/render/render.ts +53 -15
- package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
- package/telegram-plugin/rich-send.ts +22 -7
- package/telegram-plugin/shared/bot-runtime.ts +3 -2
- package/telegram-plugin/telegraph.ts +6 -4
- package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
- package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +253 -0
- package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
- package/telegram-plugin/tests/render/parse.test.ts +39 -10
- package/telegram-plugin/tests/render/render.test.ts +9 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
- package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
- package/telegram-plugin/tests/send-reply-golden.test.ts +99 -1
- package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
- package/telegram-plugin/tests/speech-capture.test.ts +296 -0
- package/telegram-plugin/tests/telegraph.test.ts +1 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
- package/telegram-plugin/tts-normalize.ts +47 -9
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
- package/telegram-plugin/voice-normalize-text.ts +48 -9
|
@@ -350,16 +350,54 @@ export function normalizeForTts(text: string): string {
|
|
|
350
350
|
|
|
351
351
|
// -- Number + unit suffix glued to the number (500ms, 5km, 10GB). Only a
|
|
352
352
|
// known unit bounded by a non-letter, so identifiers never match.
|
|
353
|
+
//
|
|
354
|
+
// The leading `(?<![\d.,])` lookbehind is load-bearing: without it
|
|
355
|
+
// `\b` is satisfied between a decimal point (or thousands comma) and
|
|
356
|
+
// the digits that follow, so "17s" inside "0.17s" — or "500s" inside
|
|
357
|
+
// "1,500s" — would match on its own and mangle the number ("0.17s" →
|
|
358
|
+
// "0.seventeen seconds"; "1,500s" → "1,five hundred seconds"). Excludes
|
|
359
|
+
// both `.` and `,` so a glued decimal/thousands-separated number is
|
|
360
|
+
// never re-anchored on. Multi-letter units (ms, GB, …) stay
|
|
361
|
+
// case-insensitive since `5MB`/`500ms` must keep working regardless of
|
|
362
|
+
// case; single-letter units (s, m, h, d, g) are matched
|
|
363
|
+
// case-sensitively lowercase-only in a second pass — the single letter
|
|
364
|
+
// "M" is one keystroke from meaning "million" in prose ("$5M",
|
|
365
|
+
// "Revenue was 5M") and must never fall through to "minutes" just
|
|
366
|
+
// because the whole alternation ran under /i.
|
|
367
|
+
//
|
|
368
|
+
// KNOWN GAP, disclosed deliberately (2026-08, hotfix for #2760-derived
|
|
369
|
+
// corpus regression): a decimal- or comma-glued number with a unit
|
|
370
|
+
// (27.5s, 1,500s, 89.5g, 0.7-0.8m) is now left completely UNEXPANDED —
|
|
371
|
+
// the unit goes unspoken — rather than mangled the way it was before.
|
|
372
|
+
// Corpus-measured: samples carrying a digit-adjacent unspoken unit go
|
|
373
|
+
// 65 → 122 (61 samples change under this fix, every one gaining an
|
|
374
|
+
// unspoken unit). Correctly expanding a decimal/comma-glued number is
|
|
375
|
+
// explicitly out of scope for this narrow hotfix — see the pinning
|
|
376
|
+
// tests below (13.0 GB / 89.5g / 27.5 s) marking the baseline for a
|
|
377
|
+
// follow-up decimal-expansion pass.
|
|
378
|
+
//
|
|
379
|
+
// ASYMMETRY WITH pass 1 (voice-normalize-text.ts), pre-existing and
|
|
380
|
+
// NOT introduced by this fix: this pass allows an optional space
|
|
381
|
+
// (`\s?`) between the number and the unit and maps `m` to METRE; pass
|
|
382
|
+
// 1 has no `\s?` and maps `m` to MINUTE. That accident is exactly what
|
|
383
|
+
// makes "5m" (no space) come out of pass 1 as minutes and "16 m"
|
|
384
|
+
// (space) come out of pass 2 as metres in the composed pipeline —
|
|
385
|
+
// pinned by the composed-pipeline test in the test suite.
|
|
386
|
+
const unitReplacer = (m: string, num: string, unitRaw: string): string => {
|
|
387
|
+
const unit = UNIT_MAP[unitRaw.toLowerCase()]
|
|
388
|
+
if (!unit) return m
|
|
389
|
+
const n = Number(num)
|
|
390
|
+
const w = numberToWords(n)
|
|
391
|
+
if (!w) return m
|
|
392
|
+
return `${w} ${n === 1 ? unit.s : unit.p}`
|
|
393
|
+
}
|
|
353
394
|
s = s.replace(
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (!w) return m
|
|
361
|
-
return `${w} ${n === 1 ? unit.s : unit.p}`
|
|
362
|
-
},
|
|
395
|
+
/(?<![\d.,])\b(\d{1,9})\s?(ms|sec|min|km|cm|mm|kg|kb|mb|gb|tb|ghz|mhz|hr|mi)(?![a-zA-Z])/gi,
|
|
396
|
+
unitReplacer,
|
|
397
|
+
)
|
|
398
|
+
s = s.replace(
|
|
399
|
+
/(?<![\d.,])\b(\d{1,9})\s?(s|m|h|d|g)(?![a-zA-Z])/g,
|
|
400
|
+
unitReplacer,
|
|
363
401
|
)
|
|
364
402
|
|
|
365
403
|
// -- Symbols in prose.
|
|
@@ -271,13 +271,16 @@ function kindsPresent(msg: ObservedMessage): Set<string> {
|
|
|
271
271
|
);
|
|
272
272
|
|
|
273
273
|
// ---------------------------------------------------------------------------
|
|
274
|
-
// Rich-render wiring proof —
|
|
274
|
+
// Rich-render wiring proof — legacy `**> ` quote repair round-trip.
|
|
275
275
|
//
|
|
276
276
|
// This is the piece the unit suite CANNOT prove: that a REAL markdown reply
|
|
277
|
-
// carrying the
|
|
277
|
+
// carrying the LEGACY switchroom expandable-quote marker (`**> `) flows
|
|
278
278
|
// through the live send path — parse.ts (marker -> IR `expandable: true`) ->
|
|
279
|
-
// render.ts (IR ->
|
|
280
|
-
//
|
|
279
|
+
// render.ts (IR -> plain `> ` markdown; `**>` is MarkdownV2-only and renders
|
|
280
|
+
// as literal text on the rich path, wire-proved 2026-08-13, so the renderer
|
|
281
|
+
// no longer emits it) -> renderSafe -> sendRichMessage — and that Telegram
|
|
282
|
+
// parses the repaired output back to a blockquote entity with NO literal
|
|
283
|
+
// `**>` glyphs reaching the reader.
|
|
281
284
|
//
|
|
282
285
|
// Runs ONLY when the rich-render flag is on in this process (see
|
|
283
286
|
// RICH_RENDER_ON) AND the driver creds are present; self-skips green
|
|
@@ -299,7 +302,7 @@ const EXPANDABLE_SAMPLE = [
|
|
|
299
302
|
"uat: expandable/collapsible content round-trips through the live renderer",
|
|
300
303
|
() => {
|
|
301
304
|
it(
|
|
302
|
-
"a `**>` reply
|
|
305
|
+
"a legacy `**>` reply is repaired to a plain quote and decodes as a blockquote entity",
|
|
303
306
|
async () => {
|
|
304
307
|
const sc = await spinUp({ agent: AGENT });
|
|
305
308
|
try {
|
|
@@ -318,9 +321,9 @@ const EXPANDABLE_SAMPLE = [
|
|
|
318
321
|
).not.toBe("\x01");
|
|
319
322
|
expect(reply.text).toContain(EXP_MARKER);
|
|
320
323
|
|
|
321
|
-
// The
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
+
// The legacy quote must survive as a real blockquote entity on the
|
|
325
|
+
// wire — proof the parse->render->send path repaired the `**>` line
|
|
326
|
+
// into the quote instead of shipping stray literal text.
|
|
324
327
|
const present = kindsPresent(reply);
|
|
325
328
|
expect(
|
|
326
329
|
[...present],
|
|
@@ -331,6 +334,12 @@ const EXPANDABLE_SAMPLE = [
|
|
|
331
334
|
// The quoted body text must round-trip (content never lost).
|
|
332
335
|
expect(reply.text).toContain("first hidden line");
|
|
333
336
|
|
|
337
|
+
// And the retired marker must never reach the reader as literal
|
|
338
|
+
// glyphs — the exact failure mode of the pre-repair renderer
|
|
339
|
+
// (Telegram rendered the `**> ` opener as a literal `**>` paragraph
|
|
340
|
+
// while the `> ` continuation lines formed a detached quote).
|
|
341
|
+
expect(reply.text).not.toContain("**>");
|
|
342
|
+
|
|
334
343
|
console.info(
|
|
335
344
|
"[uat] expandable round-trip entity structure: " +
|
|
336
345
|
JSON.stringify(
|
|
@@ -561,16 +561,55 @@ export function normalizeForSpeech(input: string): string {
|
|
|
561
561
|
// Number + unit suffix → "<number> <unit>" (e.g. 500ms, 2h, 10KB).
|
|
562
562
|
// Only when the suffix is a known unit glued directly to the number and
|
|
563
563
|
// bounded by a non-letter (so "my5thing" / "class5" are never touched).
|
|
564
|
+
//
|
|
565
|
+
// The leading `(?<![\d.,])` lookbehind is load-bearing: without it
|
|
566
|
+
// `\b` is satisfied between a decimal point (or thousands comma) and
|
|
567
|
+
// the digits that follow, so "17s" inside "0.17s" — or "500s" inside
|
|
568
|
+
// "1,500s" — would match on its own and mangle the number ("0.17s" →
|
|
569
|
+
// "0.seventeen seconds"; "1,500s" → "1,five hundred seconds"). Excludes
|
|
570
|
+
// both `.` and `,` so a glued decimal/thousands-separated number is
|
|
571
|
+
// never re-anchored on. Multi-letter units (ms, GB, …) stay
|
|
572
|
+
// case-insensitive since `5MB`/`500ms` must keep working regardless of
|
|
573
|
+
// case; single-letter units (s, m, h, d) are matched case-sensitively
|
|
574
|
+
// lowercase-only in a second pass — the single letter "M" is one
|
|
575
|
+
// keystroke from meaning "million" in prose ("$5M", "Revenue was 5M")
|
|
576
|
+
// and must never fall through to "minutes" just because the whole
|
|
577
|
+
// alternation ran under /i.
|
|
578
|
+
//
|
|
579
|
+
// KNOWN GAP, disclosed deliberately (2026-08, hotfix for a corpus
|
|
580
|
+
// regression): a decimal- or comma-glued number with a unit (27.5s,
|
|
581
|
+
// 1,500s, 89.5g, 0.7-0.8m) is now left completely UNEXPANDED — the
|
|
582
|
+
// unit goes unspoken — rather than mangled the way it was before.
|
|
583
|
+
// Corpus-measured: samples carrying a digit-adjacent unspoken unit go
|
|
584
|
+
// 65 → 122 (61 samples change under this fix, every one gaining an
|
|
585
|
+
// unspoken unit). Correctly expanding a decimal/comma-glued number is
|
|
586
|
+
// explicitly out of scope for this narrow hotfix — see the pinning
|
|
587
|
+
// tests below (13.0 GB / 89.5g / 27.5 s) marking the baseline for a
|
|
588
|
+
// follow-up decimal-expansion pass.
|
|
589
|
+
//
|
|
590
|
+
// ASYMMETRY WITH pass 2 (tts-normalize.ts), pre-existing and NOT
|
|
591
|
+
// introduced by this fix: this pass has NO optional space between the
|
|
592
|
+
// number and the unit (unlike pass 2's `\s?`) and maps `m` to MINUTE;
|
|
593
|
+
// pass 2 maps `m` to METRE. That accident is exactly what makes "5m"
|
|
594
|
+
// (no space) come out of THIS pass as minutes and "16 m" (space) fall
|
|
595
|
+
// through untouched here but come out of pass 2 as metres in the
|
|
596
|
+
// composed pipeline — pinned by the composed-pipeline test in the
|
|
597
|
+
// tts-normalize test suite.
|
|
598
|
+
const unitReplacer = (m: string, num: string, unitRaw: string): string => {
|
|
599
|
+
const unit = UNIT_MAP[unitRaw.toLowerCase()]
|
|
600
|
+
if (!unit) return m
|
|
601
|
+
const n = Number(num)
|
|
602
|
+
const w = numberToWords(n)
|
|
603
|
+
if (!w) return m
|
|
604
|
+
return `${w} ${n === 1 ? unit.s : unit.p}`
|
|
605
|
+
}
|
|
606
|
+
s = s.replace(
|
|
607
|
+
/(?<![\d.,])\b(\d{1,9})(ms|sec|min|kb|mb|gb|tb|hr)(?![a-zA-Z])/gi,
|
|
608
|
+
unitReplacer,
|
|
609
|
+
)
|
|
564
610
|
s = s.replace(
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const unit = UNIT_MAP[unitRaw.toLowerCase()]
|
|
568
|
-
if (!unit) return m
|
|
569
|
-
const n = Number(num)
|
|
570
|
-
const w = numberToWords(n)
|
|
571
|
-
if (!w) return m
|
|
572
|
-
return `${w} ${n === 1 ? unit.s : unit.p}`
|
|
573
|
-
},
|
|
611
|
+
/(?<![\d.,])\b(\d{1,9})(s|m|h|d)(?![a-zA-Z])/g,
|
|
612
|
+
unitReplacer,
|
|
574
613
|
)
|
|
575
614
|
// Symbols between tokens: standalone & → and, + → plus, = → equals.
|
|
576
615
|
s = s.replace(/(\S)\s*\+\s*(\S)/g, '$1 plus $2')
|