switchroom 0.16.38 → 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.
Files changed (61) hide show
  1. package/dist/agent-scheduler/index.js +88 -82
  2. package/dist/auth-broker/index.js +87 -81
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +89 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1621 -737
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +271 -239
  10. package/dist/vault/approvals/kernel-server.js +90 -84
  11. package/dist/vault/broker/server.js +91 -85
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +2 -2
  15. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  16. package/skills/switchroom-status/SKILL.md +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -1
  18. package/telegram-plugin/card-format.ts +7 -1
  19. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  20. package/telegram-plugin/dist/gateway/gateway.js +2090 -1046
  21. package/telegram-plugin/dist/server.js +180 -162
  22. package/telegram-plugin/format.ts +305 -31
  23. package/telegram-plugin/gateway/gateway.ts +262 -63
  24. package/telegram-plugin/gateway/model-command.ts +173 -19
  25. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  26. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  27. package/telegram-plugin/package.json +1 -1
  28. package/telegram-plugin/session-tail.ts +47 -1
  29. package/telegram-plugin/stream-reply-handler.ts +19 -1
  30. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  31. package/telegram-plugin/tests/card-format.test.ts +28 -0
  32. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  33. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  34. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  35. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  36. package/telegram-plugin/tests/model-command.test.ts +213 -47
  37. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  38. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  40. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  41. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  42. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  43. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  44. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  45. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  46. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  47. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  48. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  49. package/telegram-plugin/text-voice-scrub.ts +68 -18
  50. package/telegram-plugin/tool-activity-summary.ts +20 -108
  51. package/telegram-plugin/tts-normalize.ts +377 -0
  52. package/telegram-plugin/uat/driver.ts +472 -22
  53. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  54. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  56. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  57. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  58. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  59. package/telegram-plugin/voice-ondemand.ts +161 -10
  60. package/telegram-plugin/voice-presynth.ts +242 -0
  61. package/telegram-plugin/worker-activity-feed.ts +9 -1
@@ -48,11 +48,71 @@ describe('normalizeParagraphBreaks', () => {
48
48
  expect(normalizeParagraphBreaks(input)).toBe(input)
49
49
  })
50
50
 
51
- test('does NOT promote when the next line is a list item (tight list stays tight)', () => {
52
- const input = 'Here are the steps.\n- first\n- second\n- third'
51
+ test('promotes every interior break in a vertical stat card (#2750)', () => {
52
+ // The gymbro food-log card: lines end in digits/letters (no terminator),
53
+ // but each is a standalone `label: value` stat line, so every interior
54
+ // break must become a GFM hard break instead of collapsing to one wall.
55
+ const input = 'Calories: 1800\nProtein: 120g\nCarbs: 200g'
56
+ expect(normalizeParagraphBreaks(input)).toBe(
57
+ 'Calories: 1800 \nProtein: 120g \nCarbs: 200g',
58
+ )
59
+ })
60
+
61
+ test('promotes a key-value line followed by prose (#2750)', () => {
62
+ const input = 'Status: active\nEverything is running smoothly.'
63
+ expect(normalizeParagraphBreaks(input)).toBe(
64
+ 'Status: active \nEverything is running smoothly.',
65
+ )
66
+ })
67
+
68
+ test('promotes bold-label stat cards — colon inside AND outside the emphasis (#2750)', () => {
69
+ // The fleet HOUSE style uses markdown-bold labels. Both `**Calories:** 1800`
70
+ // (colon inside the bold) and `**Calories**: 1800` (colon outside) must have
71
+ // every interior break promoted.
72
+ const inside = '**Calories:** 1800\n**Protein:** 120g\n**Carbs:** 200g'
73
+ expect(normalizeParagraphBreaks(inside)).toBe(
74
+ '**Calories:** 1800 \n**Protein:** 120g \n**Carbs:** 200g',
75
+ )
76
+ const outside = '**Calories**: 1800\n**Protein**: 120g'
77
+ expect(normalizeParagraphBreaks(outside)).toBe('**Calories**: 1800 \n**Protein**: 120g')
78
+ })
79
+
80
+ test('regression guard: a mid-sentence colon soft-wrap still does NOT promote (#2750)', () => {
81
+ // A wrapped sentence that happens to contain a colon must NOT be mistaken
82
+ // for a stat line: the next line continues the sentence in lowercase.
83
+ const input = 'He made one point: the plan was sound and\nthe timing was right too'
84
+ expect(normalizeParagraphBreaks(input)).toBe(input)
85
+ })
86
+
87
+ test('regression guard: a long colon-clause with a CAPITAL continuation does NOT promote (#2750)', () => {
88
+ // The lowercase-continuation guard alone would miss this: the wrapped clause
89
+ // continues with a capitalised proper noun. The label before the colon is a
90
+ // long multi-word run (not a short stat label), so the word/length cap keeps
91
+ // it from being mistaken for a stat line.
92
+ const input = 'The deal has one catch: the buyer wants it and\nToronto lawyers must sign off'
53
93
  expect(normalizeParagraphBreaks(input)).toBe(input)
54
94
  })
55
95
 
96
+ test('lowercase-label stat cards are an ACCEPTED false-negative (not promoted) (#2750)', () => {
97
+ // `calories: 1800\nprotein: 120g` — the lowercase-started next line is
98
+ // indistinguishable from a mid-sentence soft-wrap continuation, so the
99
+ // conservative guard leaves it alone. Documented, not fixed: models are
100
+ // steered toward capitalised / bold labels, and a false-negative (un-promoted
101
+ // break) is the module's preferred failure mode over a false-positive.
102
+ const input = 'calories: 1800\nprotein: 120g'
103
+ expect(normalizeParagraphBreaks(input)).toBe(input)
104
+ })
105
+
106
+ test('list glued to prose gains a block-boundary blank line; interior stays tight', () => {
107
+ // Uniform-block-spacing: a list start glued to prose by a single `\n`
108
+ // gets a blank line (so the spacer pass can see the transition); the
109
+ // list's own single-`\n` interior is never touched.
110
+ const input = 'Here are the steps.\n- first\n- second\n- third'
111
+ expect(normalizeParagraphBreaks(input)).toBe(
112
+ 'Here are the steps.\n\n- first\n- second\n- third',
113
+ )
114
+ })
115
+
56
116
  test('does NOT promote between list items (ordered or unordered)', () => {
57
117
  const ul = '- alpha.\n- beta.\n- gamma.'
58
118
  const ol = '1. first.\n2. second.\n3. third.'
@@ -275,6 +335,37 @@ describe('normalizeParagraphBreaks', () => {
275
335
  const input = '- first item\n continued text of the first item'
276
336
  expect(normalizeParagraphBreaks(input)).toBe(input)
277
337
  })
338
+
339
+ test('F2: prose with an interior ` | ` gets normal paragraph-break treatment (not table)', () => {
340
+ // `isMarkerLine` used to treat any line containing ` | ` as a table row and
341
+ // suppress the paragraph-break promotion. A sentence like "choose A | B" is
342
+ // prose, not a table — the lone `\n` after a terminated prose line must
343
+ // still be promoted to a GFM hard break.
344
+ const input = 'Pick one option, choose A | B here.\nThen continue below.'
345
+ expect(normalizeParagraphBreaks(input)).toBe(
346
+ 'Pick one option, choose A | B here. \nThen continue below.',
347
+ )
348
+ })
349
+
350
+ test('F2: a genuine table (header + delimiter) is still detected as structural', () => {
351
+ // The tightened check must still recognise a real GFM table: a blank line
352
+ // is inserted before the header and the rows stay contiguous.
353
+ const input = 'Here is data.\n| a | b |\n| --- | --- |\n| 1 | 2 |'
354
+ expect(normalizeParagraphBreaks(input)).toBe(
355
+ 'Here is data.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |',
356
+ )
357
+ })
358
+
359
+ test('F4: blank line inserted after a closed code fence before glued prose', () => {
360
+ // Prose glued directly onto a fence close (single `\n`) can be swallowed /
361
+ // mis-parsed. A blank line after a closed fence is always CommonMark-safe.
362
+ const input = 'Intro.\n\n```\nconst x = 1\n```\nThen the prose continues.'
363
+ const out = normalizeParagraphBreaks(input)
364
+ // The fence interior is untouched...
365
+ expect(out).toContain('```\nconst x = 1\n```')
366
+ // ...and a blank line now separates the fence close from the following prose.
367
+ expect(out).toContain('```\n\nThen the prose continues.')
368
+ })
278
369
  })
279
370
 
280
371
  // ---------------------------------------------------------------------------
@@ -383,40 +474,48 @@ describe('addParagraphSpacers', () => {
383
474
  expect(addParagraphSpacers(input)).toBe(input)
384
475
  })
385
476
 
386
- test('does NOT space a gap adjacent to a list (tight list rhythm preserved)', () => {
387
- const input = 'Here are the steps.\n\n- first\n- second'
388
- expect(addParagraphSpacers(input)).toBe(input)
389
- const input2 = '- first\n- second\n\nClosing prose after the list.'
390
- expect(addParagraphSpacers(input2)).toBe(input2)
477
+ test('spaces prose↔list transitions (uniform block spacing), list interior tight', () => {
478
+ expect(addParagraphSpacers('Here are the steps.\n\n- first\n- second')).toBe(
479
+ `Here are the steps.${gap}- first\n- second`,
480
+ )
481
+ expect(addParagraphSpacers('- first\n- second\n\nClosing prose after the list.')).toBe(
482
+ `- first\n- second${gap}Closing prose after the list.`,
483
+ )
391
484
  })
392
485
 
393
- test('does NOT space a gap adjacent to a table', () => {
394
- const input = 'Intro.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |'
395
- expect(addParagraphSpacers(input)).toBe(input)
486
+ test('spaces a prose→table boundary; table rows stay contiguous', () => {
487
+ const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
488
+ expect(addParagraphSpacers(`Intro.\n\n${table}`)).toBe(`Intro.${gap}${table}`)
396
489
  })
397
490
 
398
- test('does NOT space a gap adjacent to a fenced code block', () => {
491
+ test('spaces prose↔fence boundaries; fence interior untouched', () => {
399
492
  const input = 'Look here.\n\n```js\nconst a = 1;\n```\n\nDone.'
400
- // Neither the prose→fence nor the fence→prose gap gets a spacer.
401
- expect(addParagraphSpacers(input)).toBe(input)
493
+ expect(addParagraphSpacers(input)).toBe(
494
+ `Look here.${gap}\`\`\`js\nconst a = 1;\n\`\`\`${gap}Done.`,
495
+ )
402
496
  })
403
497
 
404
- test('does NOT space a gap adjacent to a heading or blockquote', () => {
498
+ test('spaces heading→anything and blockquote boundaries', () => {
405
499
  expect(addParagraphSpacers('Intro prose.\n\n## Section\n\nBody prose.')).toBe(
406
- 'Intro prose.\n\n## Section\n\nBody prose.',
500
+ `Intro prose.${gap}## Section${gap}Body prose.`,
407
501
  )
408
502
  expect(addParagraphSpacers('Said.\n\n> a quote\n\nAfter.')).toBe(
409
- 'Said.\n\n> a quote\n\nAfter.',
503
+ `Said.${gap}> a quote${gap}After.`,
410
504
  )
411
505
  })
412
506
 
507
+ test('does NOT space between items of the same loose list / same-kind blocks', () => {
508
+ const looseList = '- first\n\n- second\n\n- third'
509
+ expect(addParagraphSpacers(looseList)).toBe(looseList)
510
+ const quotes = '> one\n\n> two'
511
+ expect(addParagraphSpacers(quotes)).toBe(quotes)
512
+ })
513
+
413
514
  test('never reaches inside a fenced block (interior blank line untouched)', () => {
414
515
  const input = 'Before.\n\n```\nline 1\n\nline 2\n```\n\nAfter.'
415
516
  const out = addParagraphSpacers(input)
416
517
  // The fence interior — including its own blank line — is byte-for-byte intact.
417
518
  expect(out).toContain('```\nline 1\n\nline 2\n```')
418
- // And no spacer paragraph was injected anywhere (both gaps touch the fence).
419
- expect(out).not.toContain(PARAGRAPH_SPACER)
420
519
  })
421
520
 
422
521
  test('a body with no paragraph gap is returned unchanged', () => {
@@ -435,9 +534,9 @@ describe('addParagraphSpacers', () => {
435
534
  const out = addParagraphSpacers(normalizeParagraphBreaks(input))
436
535
  // The two prose paragraphs gain a visible spacer between them.
437
536
  expect(out).toContain(`Summary of the change.${gap}It does two things.`)
438
- // The list stays tight (no spacer wedged before it).
439
- expect(out).toContain('- adds a normalizer\n- lifts the cap')
440
- expect(out).not.toContain(`${PARAGRAPH_SPACER}\n\n- adds`)
537
+ // The prose→list transition gains a spacer too (uniform block spacing),
538
+ // but the list INTERIOR stays tight.
539
+ expect(out).toContain(`It does two things.${gap}- adds a normalizer\n- lifts the cap`)
441
540
  })
442
541
  })
443
542
 
@@ -470,3 +569,86 @@ describe('normalizeParagraphBreaks — inline bullet split integration', () => {
470
569
  expect(twice).toBe(once)
471
570
  })
472
571
  })
572
+
573
+ /**
574
+ * Regression guard for the stray whitespace-only blank line between paragraphs
575
+ * (the "\n\n \n\n" seen in a real reply). A model (or an upstream transform)
576
+ * that authors a blank line whose only content is ASCII whitespace leaves a
577
+ * ragged / oversized gap: CommonMark discards it so it buys no visible space,
578
+ * but it reads as noise in the raw text. Step 1 of normalizeParagraphBreaks now
579
+ * collapses any run of blank lines — including whitespace-only interior lines —
580
+ * to exactly one clean `\n\n`, WITHOUT ever eating the deliberate U+00A0
581
+ * paragraph spacer that addParagraphSpacers (#2692) adds later for a visible
582
+ * gap on the rich-message path.
583
+ */
584
+ describe('normalizeParagraphBreaks — whitespace-only blank-line collapse (Bug 2)', () => {
585
+ const NBSP = ' '
586
+
587
+ test('a lone-space blank line between paragraphs collapses to a clean `\\n\\n` (real string)', () => {
588
+ const input =
589
+ "Here's what was fixed and how it validates.\n\n \n\n" +
590
+ 'One loose end: a stray thing.'
591
+ expect(normalizeParagraphBreaks(input)).toBe(
592
+ "Here's what was fixed and how it validates.\n\n" +
593
+ 'One loose end: a stray thing.',
594
+ )
595
+ })
596
+
597
+ test('a single whitespace-only blank line (`A\\n \\nB`) collapses to `\\n\\n`', () => {
598
+ expect(normalizeParagraphBreaks('Alpha para.\n \nBravo para.')).toBe(
599
+ 'Alpha para.\n\nBravo para.',
600
+ )
601
+ expect(normalizeParagraphBreaks('Alpha para.\n\t\nBravo para.')).toBe(
602
+ 'Alpha para.\n\nBravo para.',
603
+ )
604
+ })
605
+
606
+ test('multiple stray whitespace-only lines in one gap collapse to a single `\\n\\n`', () => {
607
+ expect(normalizeParagraphBreaks('A.\n\n \n\n \n\nB.')).toBe('A.\n\nB.')
608
+ })
609
+
610
+ test('a legitimate single `\\n\\n` paragraph break is preserved exactly', () => {
611
+ expect(normalizeParagraphBreaks('Alpha para.\n\nBravo para.')).toBe(
612
+ 'Alpha para.\n\nBravo para.',
613
+ )
614
+ })
615
+
616
+ test('never EMITS a whitespace-only line between two prose paragraphs', () => {
617
+ const out = normalizeParagraphBreaks('First.\n\n \n\nSecond.')
618
+ for (const line of out.split('\n')) {
619
+ // Every line is either empty or has real (non-ASCII-whitespace) content.
620
+ expect(line === '' || /\S/.test(line) || line.includes(NBSP)).toBe(true)
621
+ }
622
+ expect(out).toBe('First.\n\nSecond.')
623
+ })
624
+
625
+ test('a blank line inside a fenced code block (masked) is NOT collapsed', () => {
626
+ const input = '```\ncode line 1\n \ncode line 2\n```'
627
+ // The whitespace-only line lives inside a masked fence and must survive.
628
+ expect(normalizeParagraphBreaks(input)).toBe(input)
629
+ })
630
+
631
+ test('trailing hard-break spaces on a CONTENT line are not eaten', () => {
632
+ // "line. \n\nNext." — the two trailing spaces are a GFM hard break on a
633
+ // content line, not a blank line; they must survive.
634
+ const input = 'Trailing spaces on line. \n\nNext.'
635
+ expect(normalizeParagraphBreaks(input)).toBe(input)
636
+ })
637
+
638
+ test('the deliberate U+00A0 spacer from addParagraphSpacers is preserved (no #2692 regression)', () => {
639
+ const normalized = normalizeParagraphBreaks('Alpha para.\n\nBravo para.')
640
+ const spaced = addParagraphSpacers(normalized)
641
+ // addParagraphSpacers wedges a U+00A0-only line to force a visible gap.
642
+ expect(spaced).toContain('\n\n' + PARAGRAPH_SPACER + '\n\n')
643
+ expect(spaced.split('\n')).toContain(NBSP)
644
+ // And re-running the normalizer must NOT eat that intentional spacer.
645
+ expect(normalizeParagraphBreaks(spaced)).toBe(spaced)
646
+ })
647
+
648
+ test('idempotent: collapsing a stray gap twice is stable', () => {
649
+ const once = normalizeParagraphBreaks('A.\n\n \n\nB.')
650
+ const twice = normalizeParagraphBreaks(once)
651
+ expect(twice).toBe(once)
652
+ expect(once).toBe('A.\n\nB.')
653
+ })
654
+ })