switchroom 0.13.18 → 0.13.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.
@@ -47331,8 +47331,8 @@ var {
47331
47331
  } = import__.default;
47332
47332
 
47333
47333
  // src/build-info.ts
47334
- var VERSION = "0.13.18";
47335
- var COMMIT_SHA = "847c860e";
47334
+ var VERSION = "0.13.20";
47335
+ var COMMIT_SHA = "9962efb4";
47336
47336
 
47337
47337
  // src/cli/agent.ts
47338
47338
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.18",
3
+ "version": "0.13.20",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37062,6 +37062,9 @@ function decideSilentReplyAnchor(input) {
37062
37062
  if (!input.effectivelySilent) {
37063
37063
  return { kind: "fresh", becomesAnchor: false };
37064
37064
  }
37065
+ if (input.wasOverPingSuppressed === true) {
37066
+ return { kind: "fresh", becomesAnchor: false };
37067
+ }
37065
37068
  if (input.hasFiles || input.hasButtons) {
37066
37069
  return { kind: "fresh", becomesAnchor: false };
37067
37070
  }
@@ -40352,6 +40355,90 @@ function getOpenTags(html) {
40352
40355
  return tagStack;
40353
40356
  }
40354
40357
 
40358
+ // text-voice-scrub.ts
40359
+ var NULL = "\x00";
40360
+ var FENCE_PH = `${NULL}VS_FENCE`;
40361
+ var INLINE_PH = `${NULL}VS_INLINE`;
40362
+ var HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
40363
+ var HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
40364
+ var URL_PH = `${NULL}VS_URL`;
40365
+ var URL_RE = /https?:\/\/\S+/g;
40366
+ function enabled4() {
40367
+ const v = process.env.SWITCHROOM_DISABLE_VOICE_SCRUB;
40368
+ return !(v === "1" || v === "true");
40369
+ }
40370
+ function park(text) {
40371
+ const parts = [];
40372
+ let parked = text;
40373
+ parked = parked.replace(/```[\s\S]*?```/g, (m) => {
40374
+ const idx = parts.length;
40375
+ parts.push({ prefix: FENCE_PH, idx, raw: m });
40376
+ return `${FENCE_PH}${idx}${NULL}`;
40377
+ });
40378
+ parked = parked.replace(/<pre>[\s\S]*?<\/pre>/gi, (m) => {
40379
+ const idx = parts.length;
40380
+ parts.push({ prefix: HTML_PRE_PH, idx, raw: m });
40381
+ return `${HTML_PRE_PH}${idx}${NULL}`;
40382
+ });
40383
+ parked = parked.replace(/<code[^>]*>[\s\S]*?<\/code>/gi, (m) => {
40384
+ const idx = parts.length;
40385
+ parts.push({ prefix: HTML_CODE_PH, idx, raw: m });
40386
+ return `${HTML_CODE_PH}${idx}${NULL}`;
40387
+ });
40388
+ parked = parked.replace(/`[^`\n]+`/g, (m) => {
40389
+ const idx = parts.length;
40390
+ parts.push({ prefix: INLINE_PH, idx, raw: m });
40391
+ return `${INLINE_PH}${idx}${NULL}`;
40392
+ });
40393
+ parked = parked.replace(URL_RE, (m) => {
40394
+ const idx = parts.length;
40395
+ parts.push({ prefix: URL_PH, idx, raw: m });
40396
+ return `${URL_PH}${idx}${NULL}`;
40397
+ });
40398
+ return { parked, parts };
40399
+ }
40400
+ function restore(text, parts) {
40401
+ let restored = text;
40402
+ for (let i = parts.length - 1;i >= 0; i--) {
40403
+ const p = parts[i];
40404
+ restored = restored.replace(`${p.prefix}${p.idx}${NULL}`, () => p.raw);
40405
+ }
40406
+ return restored;
40407
+ }
40408
+ function replaceDashes(text) {
40409
+ let replaced = 0;
40410
+ let out = text;
40411
+ out = out.replace(/(\S) [\u2014\u2013] (\S)/g, (_m, before, after) => {
40412
+ replaced++;
40413
+ const sentenceStart = /[A-Z]/.test(after);
40414
+ return sentenceStart ? `${before}. ${after}` : `${before}, ${after}`;
40415
+ });
40416
+ out = out.replace(/ [\u2014\u2013](\s*\n)/g, (_m, ws) => {
40417
+ replaced++;
40418
+ return `.${ws}`;
40419
+ });
40420
+ out = out.replace(/(\w)[\u2014\u2013](\w)/g, (_m, before, after) => {
40421
+ replaced++;
40422
+ return `${before}, ${after}`;
40423
+ });
40424
+ out = out.replace(/[\u2014\u2013]/g, () => {
40425
+ replaced++;
40426
+ return "-";
40427
+ });
40428
+ return { out, replaced };
40429
+ }
40430
+ function scrubVoice(text) {
40431
+ if (!enabled4() || text.length === 0) {
40432
+ return { scrubbed: text, replaced: 0 };
40433
+ }
40434
+ const { parked, parts } = park(text);
40435
+ const { out, replaced } = replaceDashes(parked);
40436
+ if (replaced === 0) {
40437
+ return { scrubbed: text, replaced: 0 };
40438
+ }
40439
+ return { scrubbed: restore(out, parts), replaced };
40440
+ }
40441
+
40355
40442
  // telegram-button-constraints.ts
40356
40443
  var TELEGRAM_BUTTON_LIMITS = {
40357
40444
  TEXT_MAX: 64,
@@ -44636,9 +44723,9 @@ function transition(state3, event) {
44636
44723
 
44637
44724
  // gateway/inbound-delivery-machine-shadow.ts
44638
44725
  var state3 = initialState();
44639
- var enabled4 = process.env.SWITCHROOM_DELIVERY_MACHINE_SHADOW !== "0";
44726
+ var enabled5 = process.env.SWITCHROOM_DELIVERY_MACHINE_SHADOW !== "0";
44640
44727
  function shadowEmit(event) {
44641
- if (!enabled4)
44728
+ if (!enabled5)
44642
44729
  return [];
44643
44730
  try {
44644
44731
  const result = transition(state3, event);
@@ -44696,12 +44783,12 @@ function redeliverBufferedInbound2(buffer, agent, send, spool) {
44696
44783
  }
44697
44784
 
44698
44785
  // gateway/inbound-delivery-machine-dispatch.ts
44699
- var enabled5 = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== "0";
44786
+ var enabled6 = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== "0";
44700
44787
  function isDispatchEnabled() {
44701
- return enabled5;
44788
+ return enabled6;
44702
44789
  }
44703
44790
  function dispatchEffects(effects, ctx) {
44704
- if (!enabled5)
44791
+ if (!enabled6)
44705
44792
  return;
44706
44793
  for (const effect of effects) {
44707
44794
  dispatchOne(effect, ctx);
@@ -48204,10 +48291,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48204
48291
  }
48205
48292
 
48206
48293
  // ../src/build-info.ts
48207
- var VERSION = "0.13.18";
48208
- var COMMIT_SHA = "847c860e";
48209
- var COMMIT_DATE = "2026-05-23T06:38:45Z";
48210
- var LATEST_PR = 1680;
48294
+ var VERSION = "0.13.20";
48295
+ var COMMIT_SHA = "9962efb4";
48296
+ var COMMIT_DATE = "2026-05-23T08:29:36Z";
48297
+ var LATEST_PR = 1684;
48211
48298
  var COMMITS_AHEAD_OF_TAG = 0;
48212
48299
 
48213
48300
  // gateway/boot-version.ts
@@ -50647,6 +50734,18 @@ async function executeReply(args) {
50647
50734
  if (rawText == null || rawText === "")
50648
50735
  throw new Error("reply: text is required and cannot be empty");
50649
50736
  let text = repairEscapedWhitespace(rawText);
50737
+ {
50738
+ const scrub = scrubVoice(text);
50739
+ if (scrub.replaced > 0) {
50740
+ text = scrub.scrubbed;
50741
+ emitRuntimeMetric({
50742
+ kind: "voice_scrub_applied",
50743
+ chatKey: statusKey(chat_id, args.message_thread_id != null ? Number(args.message_thread_id) : undefined),
50744
+ replaced: scrub.replaced,
50745
+ site: "reply"
50746
+ });
50747
+ }
50748
+ }
50650
50749
  process.stderr.write(`telegram channel: reply: invoked chatId=${chat_id} charCount=${text.length} preview=${JSON.stringify(text.slice(0, 80))}
50651
50750
  `);
50652
50751
  {
@@ -50668,6 +50767,7 @@ async function executeReply(args) {
50668
50767
  const format = args.format ?? configParseMode;
50669
50768
  const disableLinkPreview = args.disable_web_page_preview != null ? Boolean(args.disable_web_page_preview) : access.disableLinkPreview ?? true;
50670
50769
  let disableNotification = args.disable_notification === true;
50770
+ let wasOverPingSuppressed = false;
50671
50771
  {
50672
50772
  const turn2 = currentTurn;
50673
50773
  if (turn2 != null) {
@@ -50686,6 +50786,7 @@ async function executeReply(args) {
50686
50786
  sinceFirstPingMs: decision.sinceFirstPingMs ?? 0
50687
50787
  });
50688
50788
  disableNotification = true;
50789
+ wasOverPingSuppressed = true;
50689
50790
  } else if (decision.claimSlot) {
50690
50791
  turn2.firstPingAt = now;
50691
50792
  }
@@ -50794,7 +50895,8 @@ ${url}`;
50794
50895
  anchorText: turn2.silentAnchorText,
50795
50896
  newReplyText: effectiveText,
50796
50897
  hasFiles: files.length > 0,
50797
- hasButtons: replyMarkup != null
50898
+ hasButtons: replyMarkup != null,
50899
+ wasOverPingSuppressed
50798
50900
  });
50799
50901
  if (decision.kind === "edit-anchor") {
50800
50902
  const editParams = {
@@ -51704,7 +51806,19 @@ async function executeEditMessage(args) {
51704
51806
  const editAccess = loadAccess();
51705
51807
  const editConfigMode = editAccess.parseMode ?? "html";
51706
51808
  const editFormat = args.format ?? editConfigMode;
51707
- const editRawText = repairEscapedWhitespace(args.text);
51809
+ let editRawText = repairEscapedWhitespace(args.text);
51810
+ {
51811
+ const scrub = scrubVoice(editRawText);
51812
+ if (scrub.replaced > 0) {
51813
+ editRawText = scrub.scrubbed;
51814
+ emitRuntimeMetric({
51815
+ kind: "voice_scrub_applied",
51816
+ chatKey: statusKey(args.chat_id, undefined),
51817
+ replaced: scrub.replaced,
51818
+ site: "edit_message"
51819
+ });
51820
+ }
51821
+ }
51708
51822
  let editParseMode;
51709
51823
  let editText;
51710
51824
  if (editFormat === "html") {
@@ -154,6 +154,7 @@ const SILENT_END_FALLBACK_TEXT =
154
154
  '⚠️ The agent finished working but didn’t send a reply — your last ' +
155
155
  'message may not have been answered. Please try asking again.'
156
156
  import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
157
+ import { scrubVoice } from '../text-voice-scrub.js'
157
158
  import {
158
159
  validateInlineKeyboard,
159
160
  type AnyButton,
@@ -4197,6 +4198,26 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4197
4198
  const rawText = args.text as string | undefined
4198
4199
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
4199
4200
  let text = repairEscapedWhitespace(rawText)
4201
+ // Voice scrub (#1683): replace em / en dashes with commas / periods.
4202
+ // Runs BEFORE outboundDedup so retries see the scrubbed key, and
4203
+ // BEFORE markdownToHtml so code-block content is correctly parked
4204
+ // by the scrubber's own placeholder pass (otherwise the html
4205
+ // converter would have already escaped/parked code, and the scrub
4206
+ // would see only the parked placeholders). Kill switch:
4207
+ // `SWITCHROOM_DISABLE_VOICE_SCRUB=1`.
4208
+ {
4209
+ const scrub = scrubVoice(text)
4210
+ if (scrub.replaced > 0) {
4211
+ text = scrub.scrubbed
4212
+ emitRuntimeMetric({
4213
+ kind: 'voice_scrub_applied',
4214
+ chatKey: statusKey(chat_id, args.message_thread_id != null
4215
+ ? Number(args.message_thread_id) : undefined),
4216
+ replaced: scrub.replaced,
4217
+ site: 'reply',
4218
+ })
4219
+ }
4220
+ }
4200
4221
  process.stderr.write(`telegram channel: reply: invoked chatId=${chat_id} charCount=${text.length} preview=${JSON.stringify(text.slice(0, 80))}\n`)
4201
4222
 
4202
4223
  // #546 dedup check: was this content just sent via turn-flush or
@@ -4252,6 +4273,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4252
4273
  // and subsequent pings would be silenced. Acceptable trade-off (a
4253
4274
  // failed first ping is an edge case; the alternative — claim after
4254
4275
  // send — races concurrent reply calls).
4276
+ // Tracks whether the over-ping safety net coerced this reply
4277
+ // from ping→silent. Threaded into the silent-anchor predicate
4278
+ // below: a demoted final-answer reply must NOT merge into the
4279
+ // silent preamble bubble; it lands as a fresh silent bubble so
4280
+ // the user can still find it (see #1674 / silent-anchor follow-up).
4281
+ let wasOverPingSuppressed = false
4255
4282
  {
4256
4283
  const turn = currentTurn
4257
4284
  if (turn != null) {
@@ -4278,6 +4305,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4278
4305
  sinceFirstPingMs: decision.sinceFirstPingMs ?? 0,
4279
4306
  })
4280
4307
  disableNotification = true
4308
+ wasOverPingSuppressed = true
4281
4309
  } else if (decision.claimSlot) {
4282
4310
  turn.firstPingAt = now
4283
4311
  }
@@ -4445,6 +4473,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4445
4473
  newReplyText: effectiveText,
4446
4474
  hasFiles: files.length > 0,
4447
4475
  hasButtons: replyMarkup != null,
4476
+ wasOverPingSuppressed,
4448
4477
  })
4449
4478
  if (decision.kind === 'edit-anchor') {
4450
4479
  const editParams: {
@@ -5834,7 +5863,23 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
5834
5863
  const editAccess = loadAccess()
5835
5864
  const editConfigMode = editAccess.parseMode ?? 'html'
5836
5865
  const editFormat = (args.format as string | undefined) ?? editConfigMode
5837
- const editRawText = repairEscapedWhitespace(args.text as string)
5866
+ let editRawText = repairEscapedWhitespace(args.text as string)
5867
+ // Voice scrub (#1683): same em-dash scrub as the reply path. Edits
5868
+ // are how silent-anchor and progress-update mutate already-sent
5869
+ // bubbles, so without this an edit can re-introduce dashes the
5870
+ // original send had scrubbed out.
5871
+ {
5872
+ const scrub = scrubVoice(editRawText)
5873
+ if (scrub.replaced > 0) {
5874
+ editRawText = scrub.scrubbed
5875
+ emitRuntimeMetric({
5876
+ kind: 'voice_scrub_applied',
5877
+ chatKey: statusKey(args.chat_id as string, undefined),
5878
+ replaced: scrub.replaced,
5879
+ site: 'edit_message',
5880
+ })
5881
+ }
5882
+ }
5838
5883
  let editParseMode: 'HTML' | 'MarkdownV2' | undefined
5839
5884
  let editText: string
5840
5885
  if (editFormat === 'html') {
@@ -142,6 +142,24 @@ export type RuntimeMetricEvent =
142
142
  key: string
143
143
  sinceFirstPingMs: number
144
144
  }
145
+ /**
146
+ * Voice scrubber engaged: em / en dashes were rewritten to commas /
147
+ * periods on an outbound reply. Each event is a soft-layer policy
148
+ * violation the framework caught (SOUL.md.hbs "never use em-dashes"
149
+ * is the soft layer, this scrub is the hard layer). Fleet-wide
150
+ * trend over weeks shows whether the soft prompt is gaining or
151
+ * losing ground; a per-agent spike is prompt drift on that agent.
152
+ *
153
+ * chatKey → `<chatId>:<threadIdOrEmpty>` (statusKey shape)
154
+ * replaced → count of dashes rewritten in this single message
155
+ * site → which reply path saw the scrub (executeReply / edit / answer-stream)
156
+ */
157
+ | {
158
+ kind: 'voice_scrub_applied'
159
+ chatKey: string
160
+ replaced: number
161
+ site: 'reply' | 'edit_message' | 'progress_update' | 'answer_stream'
162
+ }
145
163
 
146
164
  /**
147
165
  * The JSONL sink lives under the runtime state dir so it's per-agent
@@ -65,6 +65,15 @@ export interface SilentReplyAnchorDecisionInput {
65
65
  * are too easy to get wrong, and the markup is rare enough
66
66
  * that fresh-send is the safer default. */
67
67
  hasButtons: boolean
68
+ /** True iff this reply was an intended-ping (model requested
69
+ * `disable_notification:false`) that the over-ping safety net
70
+ * (#1674) coerced to silent. Anchor merge MUST bypass when true:
71
+ * semantically the model intended this as a distinct/final
72
+ * delivery, and merging it into the existing silent preamble
73
+ * would bury the content (the user already stopped looking at
74
+ * the anchor bubble because earlier ticks edited it silently).
75
+ * Optional — defaults to false for non-gateway callers. */
76
+ wasOverPingSuppressed?: boolean
68
77
  }
69
78
 
70
79
  /**
@@ -108,6 +117,19 @@ export function decideSilentReplyAnchor(
108
117
  return { kind: 'fresh', becomesAnchor: false }
109
118
  }
110
119
 
120
+ // Over-ping-suppressed replies bypass the anchor. The model
121
+ // intended a ping (almost always: a final/distinct reply); the
122
+ // safety net demoted to silent so the user isn't double-beeped.
123
+ // Merging the demoted reply into the existing silent anchor
124
+ // hides it — the user has already disengaged from the bubble
125
+ // that's been edited silently for the rest of the turn. Land
126
+ // as a fresh silent bubble instead, preserving discoverability.
127
+ // Don't capture as next anchor either: this reply is the
128
+ // *answer*, not more preamble.
129
+ if (input.wasOverPingSuppressed === true) {
130
+ return { kind: 'fresh', becomesAnchor: false }
131
+ }
132
+
111
133
  // Files / buttons bypass the anchor — edit-text can't merge
112
134
  // media, and keyboards across edits are a foot-gun.
113
135
  if (input.hasFiles || input.hasButtons) {
@@ -175,4 +175,73 @@ describe('decideSilentReplyAnchor — silent replies edit a single growing ancho
175
175
  expect(d.mergedText.length).toBe(TELEGRAM_MSG_CAP)
176
176
  }
177
177
  })
178
+
179
+ // Follow-up to #1679 — when the over-ping safety net coerces a
180
+ // model-intended ping to silent, the demoted reply must NOT be
181
+ // merged into the existing silent anchor. The anchor has been
182
+ // edited silently for the whole turn; the user has long since
183
+ // disengaged. Merging the (semantically final) demoted reply
184
+ // there would hide the answer entirely.
185
+ describe('over-ping-suppressed messages bypass anchor merge', () => {
186
+ it('demoted reply with an active anchor lands as a fresh silent (not edit, not next-anchor)', () => {
187
+ const d = decideSilentReplyAnchor({
188
+ effectivelySilent: true,
189
+ anchorMessageId: 12345,
190
+ anchorText: 'on it — gathering facts',
191
+ newReplyText: 'Delivered all three steps with a wrap-up summary.',
192
+ hasFiles: false,
193
+ hasButtons: false,
194
+ wasOverPingSuppressed: true,
195
+ })
196
+ expect(d).toEqual({ kind: 'fresh', becomesAnchor: false })
197
+ })
198
+
199
+ it('demoted reply with no anchor yet also fresh-sends without capturing the anchor', () => {
200
+ // The model fired a stray ping before any silent ack; the
201
+ // safety-net demoted that ping. A demoted message is never
202
+ // anchor material — it's an answer, not preamble.
203
+ const d = decideSilentReplyAnchor({
204
+ effectivelySilent: true,
205
+ anchorMessageId: null,
206
+ anchorText: '',
207
+ newReplyText: 'Delivered all three steps with a wrap-up summary.',
208
+ hasFiles: false,
209
+ hasButtons: false,
210
+ wasOverPingSuppressed: true,
211
+ })
212
+ expect(d).toEqual({ kind: 'fresh', becomesAnchor: false })
213
+ })
214
+
215
+ it('genuinely silent reply (not over-ping-suppressed) still merges normally', () => {
216
+ // Regression guard: the new bypass must not over-fire on
217
+ // legitimate beat-3 silent ticks.
218
+ const d = decideSilentReplyAnchor({
219
+ effectivelySilent: true,
220
+ anchorMessageId: 12345,
221
+ anchorText: 'on it — gathering facts',
222
+ newReplyText: 'Step 1: hostname is example-host',
223
+ hasFiles: false,
224
+ hasButtons: false,
225
+ wasOverPingSuppressed: false,
226
+ })
227
+ expect(d).toEqual({
228
+ kind: 'edit-anchor',
229
+ messageId: 12345,
230
+ mergedText:
231
+ 'on it — gathering facts\n\nStep 1: hostname is example-host',
232
+ })
233
+ })
234
+
235
+ it('omitting wasOverPingSuppressed defaults to false (backward compat)', () => {
236
+ const d = decideSilentReplyAnchor({
237
+ effectivelySilent: true,
238
+ anchorMessageId: 12345,
239
+ anchorText: 'on it',
240
+ newReplyText: 'next thought',
241
+ hasFiles: false,
242
+ hasButtons: false,
243
+ })
244
+ expect(d.kind).toBe('edit-anchor')
245
+ })
246
+ })
178
247
  })
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Unit suite for #1683 text-voice-scrub.
3
+ *
4
+ * The fleet sample on 2026-05-23 showed 73% of outbound replies
5
+ * shipped at least one em-dash despite the SOUL.md.hbs soft rule.
6
+ * These tests pin the deterministic transform that the framework
7
+ * enforces, including the code/inline/HTML/URL preservation that
8
+ * keeps the scrub from mangling legitimate non-prose contexts.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
12
+
13
+ import { scrubVoice } from '../text-voice-scrub.js'
14
+
15
+ describe('scrubVoice — em / en dash replacement', () => {
16
+ beforeEach(() => {
17
+ delete process.env.SWITCHROOM_DISABLE_VOICE_SCRUB
18
+ })
19
+ afterEach(() => {
20
+ delete process.env.SWITCHROOM_DISABLE_VOICE_SCRUB
21
+ })
22
+
23
+ describe('mechanical rewrite of spaced dashes', () => {
24
+ it('replaces a spaced em-dash before lowercase with a comma', () => {
25
+ const r = scrubVoice('on it — checking the calendar')
26
+ expect(r.scrubbed).toBe('on it, checking the calendar')
27
+ expect(r.replaced).toBe(1)
28
+ })
29
+
30
+ it('replaces a spaced em-dash before an uppercase letter with a period', () => {
31
+ // The model often writes "Here's the result — Done." style.
32
+ const r = scrubVoice("Here's the result — Done.")
33
+ expect(r.scrubbed).toBe("Here's the result. Done.")
34
+ expect(r.replaced).toBe(1)
35
+ })
36
+
37
+ it('handles multiple em-dashes in one sentence', () => {
38
+ const r = scrubVoice('one — two — three — done')
39
+ expect(r.scrubbed).toBe('one, two, three, done')
40
+ expect(r.replaced).toBe(3)
41
+ })
42
+
43
+ it('treats en-dash (–) identically to em-dash', () => {
44
+ const r = scrubVoice('on it – checking the calendar')
45
+ expect(r.scrubbed).toBe('on it, checking the calendar')
46
+ expect(r.replaced).toBe(1)
47
+ })
48
+
49
+ it('replaces unspaced word-dash-word as a comma', () => {
50
+ // Less common but seen in tightly-typed prose.
51
+ const r = scrubVoice('flag—on or flag—off')
52
+ expect(r.scrubbed).toBe('flag, on or flag, off')
53
+ expect(r.replaced).toBe(2)
54
+ })
55
+
56
+ it('replaces end-of-line dashes with a period', () => {
57
+ const r = scrubVoice('thinking out loud —\nnext line here')
58
+ expect(r.scrubbed).toBe('thinking out loud.\nnext line here')
59
+ expect(r.replaced).toBe(1)
60
+ })
61
+
62
+ it('converts a leading-dash sentence-start to ASCII hyphen', () => {
63
+ // Quoted-style or list-bullet em-dash at message start; falls
64
+ // through to the catch-all rule.
65
+ const r = scrubVoice('— note: ship it')
66
+ expect(r.scrubbed).toBe('- note: ship it')
67
+ expect(r.replaced).toBe(1)
68
+ })
69
+ })
70
+
71
+ describe('protected regions are left alone', () => {
72
+ it('preserves dashes inside fenced code blocks', () => {
73
+ const input = 'here is code:\n```bash\nfoo --bar — baz\n```\nand prose — done'
74
+ const r = scrubVoice(input)
75
+ expect(r.scrubbed).toBe(
76
+ 'here is code:\n```bash\nfoo --bar — baz\n```\nand prose, done',
77
+ )
78
+ expect(r.replaced).toBe(1)
79
+ })
80
+
81
+ it('preserves dashes inside inline code', () => {
82
+ const r = scrubVoice('the flag `--really — keep` matters — yes')
83
+ expect(r.scrubbed).toBe('the flag `--really — keep` matters, yes')
84
+ expect(r.replaced).toBe(1)
85
+ })
86
+
87
+ it('preserves dashes inside <code> HTML tags', () => {
88
+ const r = scrubVoice('see <code>x — y</code> and note — ok')
89
+ expect(r.scrubbed).toBe('see <code>x — y</code> and note, ok')
90
+ expect(r.replaced).toBe(1)
91
+ })
92
+
93
+ it('preserves dashes inside <pre> HTML tags', () => {
94
+ const r = scrubVoice('block:\n<pre>x — y\nz — w</pre>\nend — ok')
95
+ expect(r.scrubbed).toBe('block:\n<pre>x — y\nz — w</pre>\nend, ok')
96
+ expect(r.replaced).toBe(1)
97
+ })
98
+
99
+ it('preserves dashes inside URLs', () => {
100
+ const r = scrubVoice('see https://example.com/a—b for context — ok')
101
+ expect(r.scrubbed).toBe(
102
+ 'see https://example.com/a—b for context, ok',
103
+ )
104
+ expect(r.replaced).toBe(1)
105
+ })
106
+
107
+ it('preserves a code block containing markdown that could otherwise match', () => {
108
+ // The placeholder restore must put the original raw fence back,
109
+ // not a transformed copy.
110
+ const fence =
111
+ '```\n# heading — title\nfunction f() {}\n```'
112
+ const r = scrubVoice(fence + '\ntrailing — yes')
113
+ expect(r.scrubbed).toBe(fence + '\ntrailing, yes')
114
+ expect(r.replaced).toBe(1)
115
+ })
116
+ })
117
+
118
+ describe('no-op cases', () => {
119
+ it('returns identity (same string, replaced=0) when input has no dashes', () => {
120
+ const input = 'no dashes anywhere, just commas and periods.'
121
+ const r = scrubVoice(input)
122
+ expect(r.scrubbed).toBe(input)
123
+ expect(r.replaced).toBe(0)
124
+ })
125
+
126
+ it('returns identity when input is empty', () => {
127
+ const r = scrubVoice('')
128
+ expect(r.scrubbed).toBe('')
129
+ expect(r.replaced).toBe(0)
130
+ })
131
+
132
+ it('kill switch (SWITCHROOM_DISABLE_VOICE_SCRUB=1) returns input unchanged', () => {
133
+ process.env.SWITCHROOM_DISABLE_VOICE_SCRUB = '1'
134
+ const r = scrubVoice('on it — checking')
135
+ expect(r.scrubbed).toBe('on it — checking')
136
+ expect(r.replaced).toBe(0)
137
+ })
138
+
139
+ it('kill switch accepts "true" as well as "1"', () => {
140
+ process.env.SWITCHROOM_DISABLE_VOICE_SCRUB = 'true'
141
+ const r = scrubVoice('on it — checking')
142
+ expect(r.scrubbed).toBe('on it — checking')
143
+ expect(r.replaced).toBe(0)
144
+ })
145
+ })
146
+
147
+ describe('realistic fleet samples', () => {
148
+ it('scrubs a multi-step status message', () => {
149
+ const input =
150
+ "I'll check the calendar — should take a few seconds. " +
151
+ 'Result: empty for Saturday — nothing scheduled. Anything else?'
152
+ const r = scrubVoice(input)
153
+ expect(r.scrubbed).toBe(
154
+ "I'll check the calendar, should take a few seconds. " +
155
+ 'Result: empty for Saturday, nothing scheduled. Anything else?',
156
+ )
157
+ expect(r.replaced).toBe(2)
158
+ })
159
+
160
+ it('mixed prose and code keeps the code untouched', () => {
161
+ const input =
162
+ 'Running `git status --short` — looks clean. ' +
163
+ '```\nM file.ts — modified\n```\n' +
164
+ 'Ready to commit — go?'
165
+ const r = scrubVoice(input)
166
+ expect(r.scrubbed).toBe(
167
+ 'Running `git status --short`, looks clean. ' +
168
+ '```\nM file.ts — modified\n```\n' +
169
+ 'Ready to commit, go?',
170
+ )
171
+ expect(r.replaced).toBe(2)
172
+ })
173
+ })
174
+ })
@@ -0,0 +1,199 @@
1
+ /**
2
+ * text-voice-scrub.ts — deterministic prose-style enforcement at the
3
+ * gateway.
4
+ *
5
+ * Background. Despite three landed soft fixes (SOUL.md.hbs "never use
6
+ * em-dashes" rule, PR #1177 voice consolidation, the /humanizer skill),
7
+ * sampling 2,867 recent fleet outbound replies on 2026-05-23 showed
8
+ * em-dashes still present in 73% of agent messages (3.23 per 1k chars).
9
+ * Soft layer was not winning. The operator's framing is the same one
10
+ * that drove the over-ping safety net (#1674) and the silent-reply
11
+ * auto-edit (#1677): when the model authors voice and the framework
12
+ * owns enforcement, soft instructions fail under load. Make the
13
+ * framework do it.
14
+ *
15
+ * Scope. Em / en dashes only. The wider "AI-tell phrase denylist"
16
+ * (smoking gun, by design, etc.) was scoped OUT after data showed
17
+ * those phrases land in <0.5% of fleet messages and substituting
18
+ * them risks semantic loss. Em-dash → comma/period is a pure
19
+ * mechanical transform with no semantic loss when the surrounding
20
+ * text is whitespace-separated prose, and a no-op when the dash
21
+ * is inside code or a URL.
22
+ *
23
+ * Pipeline integration. Apply BEFORE markdownToHtml so the scrub
24
+ * runs on the original model text, not on rendered HTML where
25
+ * the dash might already be tag-escaped or live inside a parked
26
+ * code-block placeholder. Apply BEFORE outboundDedup.check so
27
+ * dedup keys see the post-scrub content (same text from a retry
28
+ * collapses cleanly).
29
+ *
30
+ * Code-region awareness. The scrubber MUST preserve dashes inside:
31
+ * - fenced code blocks: ```lang\n...\n```
32
+ * - inline code: `...`
33
+ * - explicit Telegram HTML code tags: <code>...</code>, <pre>...</pre>
34
+ * - URLs (rare to contain em-dashes, but technically valid IDN)
35
+ * The strategy is to park each protected region with a sentinel,
36
+ * scrub the rest, then restore. Mirrors the well-trodden
37
+ * markdownToHtml() codeBlocks/inlineCode placeholder pattern at
38
+ * format.ts:254-272.
39
+ *
40
+ * Kill switch. `SWITCHROOM_DISABLE_VOICE_SCRUB=1` returns the input
41
+ * unchanged and reports zero replacements. Same shape every other
42
+ * gateway safety net uses; rollback is one env var + agent restart.
43
+ */
44
+
45
+ export interface VoiceScrubResult {
46
+ /** The scrubbed text. Equal to input when no replacements made or
47
+ * when the kill switch is set. */
48
+ scrubbed: string
49
+ /** Count of dash replacements made across the whole input. Surfaces
50
+ * to the runtime-metrics fan-out so the cadence dashboard can track
51
+ * fleet-wide voice-scrub rate over time. */
52
+ replaced: number
53
+ }
54
+
55
+ const NULL = '\x00'
56
+ const FENCE_PH = `${NULL}VS_FENCE`
57
+ const INLINE_PH = `${NULL}VS_INLINE`
58
+ const HTML_CODE_PH = `${NULL}VS_HTMLCODE`
59
+ const HTML_PRE_PH = `${NULL}VS_HTMLPRE`
60
+ const URL_PH = `${NULL}VS_URL`
61
+
62
+ const URL_RE = /https?:\/\/\S+/g
63
+
64
+ function enabled(): boolean {
65
+ const v = process.env.SWITCHROOM_DISABLE_VOICE_SCRUB
66
+ return !(v === '1' || v === 'true')
67
+ }
68
+
69
+ /**
70
+ * Park code-like regions behind placeholders so the dash-replacement
71
+ * pass can't touch them. Returns the parked-string and the original
72
+ * fragments keyed by index.
73
+ */
74
+ function park(text: string): {
75
+ parked: string
76
+ parts: Array<{ prefix: string; idx: number; raw: string }>
77
+ } {
78
+ const parts: Array<{ prefix: string; idx: number; raw: string }> = []
79
+ let parked = text
80
+
81
+ // Order matters: fenced first (so a ` inside a fence isn't taken
82
+ // as inline-code start), then HTML code tags, then inline backticks,
83
+ // then URLs.
84
+ parked = parked.replace(/```[\s\S]*?```/g, (m) => {
85
+ const idx = parts.length
86
+ parts.push({ prefix: FENCE_PH, idx, raw: m })
87
+ return `${FENCE_PH}${idx}${NULL}`
88
+ })
89
+ parked = parked.replace(/<pre>[\s\S]*?<\/pre>/gi, (m) => {
90
+ const idx = parts.length
91
+ parts.push({ prefix: HTML_PRE_PH, idx, raw: m })
92
+ return `${HTML_PRE_PH}${idx}${NULL}`
93
+ })
94
+ parked = parked.replace(/<code[^>]*>[\s\S]*?<\/code>/gi, (m) => {
95
+ const idx = parts.length
96
+ parts.push({ prefix: HTML_CODE_PH, idx, raw: m })
97
+ return `${HTML_CODE_PH}${idx}${NULL}`
98
+ })
99
+ parked = parked.replace(/`[^`\n]+`/g, (m) => {
100
+ const idx = parts.length
101
+ parts.push({ prefix: INLINE_PH, idx, raw: m })
102
+ return `${INLINE_PH}${idx}${NULL}`
103
+ })
104
+ parked = parked.replace(URL_RE, (m) => {
105
+ const idx = parts.length
106
+ parts.push({ prefix: URL_PH, idx, raw: m })
107
+ return `${URL_PH}${idx}${NULL}`
108
+ })
109
+
110
+ return { parked, parts }
111
+ }
112
+
113
+ function restore(
114
+ text: string,
115
+ parts: Array<{ prefix: string; idx: number; raw: string }>,
116
+ ): string {
117
+ let restored = text
118
+ // Restore in reverse-insertion order so a placeholder accidentally
119
+ // emitted by a nested replacement gets the right raw region.
120
+ for (let i = parts.length - 1; i >= 0; i--) {
121
+ const p = parts[i]!
122
+ restored = restored.replace(`${p.prefix}${p.idx}${NULL}`, () => p.raw)
123
+ }
124
+ return restored
125
+ }
126
+
127
+ /**
128
+ * Replace em / en dashes with context-appropriate punctuation.
129
+ *
130
+ * Rules, applied in order:
131
+ * 1. ` — ` / ` – ` (flanked by single space) → `, ` if followed by a
132
+ * lowercase or open-paren character; otherwise `. ` if followed by
133
+ * an uppercase or end-of-string. Heuristic: lowercase = mid-clause
134
+ * continuation (comma reads naturally); uppercase = new sentence
135
+ * (period reads naturally).
136
+ * 2. End-of-line dash (` —\n` / ` –\n`) → `.\n` — treat as full stop.
137
+ * 3. Bare dash with no flanking spaces between word chars
138
+ * (e.g. "word—word") → `, ` — the missing-space form is rarer but
139
+ * semantically the same as #1.
140
+ * 4. Surviving dash (uncommon, e.g. at sentence start "— note") → `-`
141
+ * so the message still renders without the AI tell.
142
+ */
143
+ function replaceDashes(text: string): { out: string; replaced: number } {
144
+ let replaced = 0
145
+ let out = text
146
+
147
+ // #1: spaced em-dash mid-prose. Decide between ", " and ". " on
148
+ // the leading character of the following token.
149
+ out = out.replace(/(\S) [—–] (\S)/g, (_m, before: string, after: string) => {
150
+ replaced++
151
+ // If `after` is uppercase ASCII or one of a known sentence-starter
152
+ // set, treat as new sentence; otherwise a parenthetical comma.
153
+ const sentenceStart = /[A-Z]/.test(after)
154
+ return sentenceStart ? `${before}. ${after}` : `${before}, ${after}`
155
+ })
156
+
157
+ // #2: dash at end of line. Treat as full stop.
158
+ out = out.replace(/ [—–](\s*\n)/g, (_m, ws: string) => {
159
+ replaced++
160
+ return `.${ws}`
161
+ })
162
+
163
+ // #3: bare dash between word chars (no flanking spaces). Treat as
164
+ // missing-space form of #1; comma is the safe fallback.
165
+ out = out.replace(/(\w)[—–](\w)/g, (_m, before: string, after: string) => {
166
+ replaced++
167
+ return `${before}, ${after}`
168
+ })
169
+
170
+ // #4: anything still standing — convert to ASCII hyphen so no
171
+ // typographic dash escapes the gate. Rare path; covers leading
172
+ // "— note" / quoted dash / etc.
173
+ out = out.replace(/[—–]/g, () => {
174
+ replaced++
175
+ return '-'
176
+ })
177
+
178
+ return { out, replaced }
179
+ }
180
+
181
+ /**
182
+ * Public entry: scrub em / en dashes from outbound text while
183
+ * preserving dashes inside code and URLs.
184
+ *
185
+ * Pure: no IO, no module-scope state, deterministic. Kill switch is
186
+ * checked per call so an operator can flip it via env var without a
187
+ * restart of an in-process test.
188
+ */
189
+ export function scrubVoice(text: string): VoiceScrubResult {
190
+ if (!enabled() || text.length === 0) {
191
+ return { scrubbed: text, replaced: 0 }
192
+ }
193
+ const { parked, parts } = park(text)
194
+ const { out, replaced } = replaceDashes(parked)
195
+ if (replaced === 0) {
196
+ return { scrubbed: text, replaced: 0 }
197
+ }
198
+ return { scrubbed: restore(out, parts), replaced }
199
+ }