switchroom 0.19.16 → 0.19.18

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 (66) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +11 -3
  4. package/dist/auth-broker/index.js +29 -4
  5. package/dist/cli/notion-write-pretool.mjs +11 -3
  6. package/dist/cli/switchroom.js +8307 -7620
  7. package/dist/host-control/main.js +626 -36
  8. package/dist/vault/approvals/kernel-server.js +30 -5
  9. package/dist/vault/broker/server.js +71 -18
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +8 -4
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +3 -3
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/mental-model-curator/SKILL.md +8 -6
  17. package/telegram-plugin/bridge/bridge.ts +11 -19
  18. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +15 -20
  20. package/telegram-plugin/dist/gateway/gateway.js +763 -373
  21. package/telegram-plugin/dist/server.js +19 -20
  22. package/telegram-plugin/gateway/boot-card.ts +5 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  24. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  25. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  26. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  27. package/telegram-plugin/gateway/gateway.ts +43 -1
  28. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  29. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  30. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  32. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  33. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  34. package/telegram-plugin/gateway/stream-render.ts +24 -5
  35. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  36. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  37. package/telegram-plugin/registry/turns-schema.ts +40 -12
  38. package/telegram-plugin/runtime-metrics.ts +14 -0
  39. package/telegram-plugin/silence-poke.ts +138 -0
  40. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  41. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  42. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  43. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  44. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  45. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  46. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  47. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  48. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  49. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  50. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  51. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  52. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  53. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  54. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  55. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  56. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  57. package/telegram-plugin/tts-normalize.ts +6 -4
  58. package/telegram-plugin/voice-normalize-text.ts +168 -11
  59. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  60. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  61. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  62. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  63. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  64. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  65. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  66. package/vendor/hindsight-memory/settings.json +1 -1
@@ -29,9 +29,11 @@ describe('normalizeForSpeech — reported markdown/symbol cases', () => {
29
29
  expect(normalizeForSpeech('run `npm test` now')).toBe('run npm test now')
30
30
  })
31
31
 
32
- it('strips headings markers', () => {
33
- expect(normalizeForSpeech('# Summary\nAll good')).toBe('Summary All good')
34
- expect(normalizeForSpeech('### Deep heading')).toBe('Deep heading')
32
+ it('strips headings markers and gives the heading a spoken full stop', () => {
33
+ // The heading is its own spoken sentence — without the terminator the
34
+ // newline-collapse ran the heading straight into the body text.
35
+ expect(normalizeForSpeech('# Summary\nAll good')).toBe('Summary. All good')
36
+ expect(normalizeForSpeech('### Deep heading')).toBe('Deep heading.')
35
37
  })
36
38
 
37
39
  it('speaks link text and drops the URL', () => {
@@ -118,6 +120,18 @@ describe('normalizeForSpeech — emoji & pictographs', () => {
118
120
  expect(normalizeForSpeech('nice :rocket: work')).toBe('nice work')
119
121
  })
120
122
 
123
+ // Digit-bodied shortcodes are real. A letter-only body left them in the
124
+ // text AND glued them to the previous word ("Nice:100: work").
125
+ it('drops a DIGIT-bodied :shortcode: without eating the space', () => {
126
+ expect(normalizeForSpeech('Nice :100: work')).toBe('Nice work')
127
+ expect(normalizeForSpeech('a :8ball: b')).toBe('a b')
128
+ expect(normalizeForSpeech('a :1st_place_medal: b')).toBe('a b')
129
+ })
130
+
131
+ it('the digit-bodied form still cannot eat a timestamp colon', () => {
132
+ expect(normalizeForSpeech('Ran at 14:30:46 ok')).toBe('Ran at 14:30:46 ok')
133
+ })
134
+
121
135
  it('leaves ordinary colon usage alone', () => {
122
136
  expect(normalizeForSpeech('note: this is fine')).toBe('note: this is fine')
123
137
  })
@@ -335,3 +349,198 @@ describe('normalizeForSpeech — review findings (fixpoint decode, metachar, nit
335
349
  expect(decodeHtmlEntities('Q&A test')).toBe('Q&A test')
336
350
  })
337
351
  })
352
+
353
+ describe('normalizeForSpeech — list & heading pacing', () => {
354
+ it('speaks a 4-bullet list as 4 separate sentences', () => {
355
+ const out = normalizeForSpeech(
356
+ 'Here are the steps:\n' +
357
+ '- Check the logs\n' +
358
+ '- Restart the container\n' +
359
+ '- Verify health\n' +
360
+ '- Report back',
361
+ )
362
+ expect(out).toBe(
363
+ 'Here are the steps: Check the logs. Restart the container. ' +
364
+ 'Verify health. Report back.',
365
+ )
366
+ // Four sentence-terminated spoken units after the lead-in.
367
+ const units = out
368
+ .split(/(?<=[.:])\s+/)
369
+ .filter((u) => u.trim().length > 0)
370
+ expect(units).toEqual([
371
+ 'Here are the steps:',
372
+ 'Check the logs.',
373
+ 'Restart the container.',
374
+ 'Verify health.',
375
+ 'Report back.',
376
+ ])
377
+ })
378
+
379
+ it('separates ordered-list items into sentences', () => {
380
+ expect(normalizeForSpeech('1. first\n2. second\n3. third')).toBe(
381
+ 'first. second. third.',
382
+ )
383
+ })
384
+
385
+ it('does not double punctuation when the item already ends in . ! or ?', () => {
386
+ const out = normalizeForSpeech('- Done.\n- Really?\n- Ship it!')
387
+ expect(out).toBe('Done. Really? Ship it!')
388
+ expect(out).not.toContain('..')
389
+ expect(out).not.toContain('. .')
390
+ expect(out).not.toContain('?.')
391
+ expect(out).not.toContain('!.')
392
+ })
393
+
394
+ it('terminates the line that introduces a list when it lacks punctuation', () => {
395
+ expect(normalizeForSpeech('Steps\n- one\n- two')).toBe('Steps. one. two.')
396
+ })
397
+
398
+ it('does not chop a wrapped list item mid-clause', () => {
399
+ expect(normalizeForSpeech('- a long item that\ncontinues here\n- second')).toBe(
400
+ 'a long item that continues here. second.',
401
+ )
402
+ })
403
+
404
+ it('separates heading sections into sentences', () => {
405
+ expect(normalizeForSpeech('# One\nbody one\n\n# Two\nbody two')).toBe(
406
+ 'One. body one. Two. body two',
407
+ )
408
+ })
409
+ })
410
+
411
+ describe('normalizeForSpeech — time guard (HH:MM:SS)', () => {
412
+ it('leaves a full HH:MM:SS timestamp as digits rather than half-reading it', () => {
413
+ expect(normalizeForSpeech('Done at 14:30:46 today')).toBe(
414
+ 'Done at 14:30:46 today',
415
+ )
416
+ })
417
+
418
+ // `14:30:46` survives even an unguarded scan because `30` is not a legal
419
+ // HH. These are the timestamps whose TAIL is itself a legal HH:MM — the
420
+ // ones a missing lookbehind half-reads ("09:zero o'clock").
421
+ it('leaves an on-the-hour timestamp alone (lookbehind guard)', () => {
422
+ expect(normalizeForSpeech('t 09:00:00 z')).toBe('t 09:00:00 z')
423
+ expect(normalizeForSpeech('t 12:00:15 z')).toBe('t 12:00:15 z')
424
+ expect(normalizeForSpeech('t 01:02:03 y')).toBe('t 01:02:03 y')
425
+ })
426
+
427
+ it('still speaks a plain HH:MM clock time', () => {
428
+ expect(normalizeForSpeech('Meeting at 14:30 today')).toBe(
429
+ 'Meeting at fourteen thirty today',
430
+ )
431
+ expect(normalizeForSpeech('at 9:05')).toBe("at nine oh five")
432
+ })
433
+ })
434
+
435
+ describe('normalizeForSpeech — thousands separators', () => {
436
+ it('speaks $1,000 as one thousand dollars (not "one dollar,000")', () => {
437
+ expect(normalizeForSpeech('It costs $1,000 up front')).toBe(
438
+ 'It costs one thousand dollars up front',
439
+ )
440
+ })
441
+
442
+ it('speaks a millions-scale amount', () => {
443
+ expect(normalizeForSpeech('Budget $1,234,567 total')).toBe(
444
+ 'Budget one million two hundred thirty-four thousand five hundred ' +
445
+ 'sixty-seven dollars total',
446
+ )
447
+ })
448
+
449
+ it('does not regress plain currency or ordinary comma prose', () => {
450
+ expect(normalizeForSpeech('It costs $500')).toBe('It costs five hundred dollars')
451
+ expect(normalizeForSpeech('$5.50 each')).toBe('five dollars fifty each')
452
+ expect(normalizeForSpeech('a, b, 3')).toBe('a, b, 3')
453
+ expect(normalizeForSpeech('We saw 12,500 requests')).toBe(
454
+ 'We saw 12,500 requests',
455
+ )
456
+ })
457
+
458
+ // The thousands guard must not mistake an ordinary SENTENCE comma for a
459
+ // partial digit group — that left a raw "$" unspoken.
460
+ it('still speaks currency followed by a sentence comma', () => {
461
+ expect(normalizeForSpeech('It costs $500, plus tax')).toBe(
462
+ 'It costs five hundred dollars, plus tax',
463
+ )
464
+ expect(normalizeForSpeech('We paid $1,000, then left')).toBe(
465
+ 'We paid one thousand dollars, then left',
466
+ )
467
+ })
468
+
469
+ it('still bails on a genuinely malformed amount', () => {
470
+ expect(normalizeForSpeech('$1,00 partial')).toBe('$1,00 partial')
471
+ expect(normalizeForSpeech('$5.203 odd')).toBe('$5.203 odd')
472
+ })
473
+ })
474
+
475
+ describe('normalizeForSpeech — file paths', () => {
476
+ it('speaks an absolute path as its last segment', () => {
477
+ expect(normalizeForSpeech('Check /var/log/syslog now')).toBe(
478
+ 'Check syslog now',
479
+ )
480
+ })
481
+
482
+ it('handles ~/ and relative multi-segment paths', () => {
483
+ expect(normalizeForSpeech('Edit ~/.config/nvim/init.lua please')).toBe(
484
+ 'Edit init.lua please',
485
+ )
486
+ expect(normalizeForSpeech('See src/telegram-plugin/gateway.ts')).toBe(
487
+ 'See gateway.ts',
488
+ )
489
+ })
490
+
491
+ it('says "a path" when the final segment is unspeakable noise', () => {
492
+ expect(normalizeForSpeech('Path /tmp/a1b2c3d4e5f6a7b8 there')).toBe(
493
+ 'Path a path there',
494
+ )
495
+ })
496
+
497
+ it('leaves a single-slash word/word pair for the downstream slash pass', () => {
498
+ expect(normalizeForSpeech('and/or maybe')).toBe('and/or maybe')
499
+ })
500
+
501
+ it('does not treat an all-numeric date as a path', () => {
502
+ expect(normalizeForSpeech('the date 12/25/2026 works')).toBe(
503
+ 'the date 12/25/2026 works',
504
+ )
505
+ })
506
+
507
+ // "Two or more slashes ⇒ path" is false in English. Without a path-ish
508
+ // anchor the pass DELETES words from ordinary prose ("yes/no/maybe" →
509
+ // "maybe"). Each of these must survive verbatim for the downstream
510
+ // "word slash word" pass to speak.
511
+ it('never swallows a multi-slash PROSE run (no path anchor)', () => {
512
+ expect(normalizeForSpeech('yes/no/maybe')).toBe('yes/no/maybe')
513
+ expect(normalizeForSpeech('read/write/exec perms')).toBe(
514
+ 'read/write/exec perms',
515
+ )
516
+ expect(normalizeForSpeech('he/she/they pronouns')).toBe(
517
+ 'he/she/they pronouns',
518
+ )
519
+ expect(normalizeForSpeech('a client/server/proxy split')).toBe(
520
+ 'a client/server/proxy split',
521
+ )
522
+ expect(normalizeForSpeech('reading input/output/error')).toBe(
523
+ 'reading input/output/error',
524
+ )
525
+ expect(normalizeForSpeech('tests in unit/integration/e2e are green')).toBe(
526
+ 'tests in unit/integration/e2e are green',
527
+ )
528
+ })
529
+ })
530
+
531
+ describe('normalizeForSpeech — extended acronym set', () => {
532
+ it('spells the newly-added initialisms letter-by-letter', () => {
533
+ expect(normalizeForSpeech('use HTTPS not HTTP')).toBe('use H T T P S not H T T P')
534
+ expect(normalizeForSpeech('over SSH via DNS')).toBe('over S S H via D N S')
535
+ expect(normalizeForSpeech('the CLI on AWS at UTC')).toBe(
536
+ 'the C L I on A W S at U T C',
537
+ )
538
+ expect(normalizeForSpeech('MCP PDF VM LLM YAML RAM USB ID OK')).toBe(
539
+ 'M C P P D F V M L L M Y A M L R A M U S B I D O K',
540
+ )
541
+ })
542
+
543
+ it('still leaves word-style all-caps tokens alone', () => {
544
+ expect(normalizeForSpeech('NASA ALWAYS SHOUTING')).toBe('NASA ALWAYS SHOUTING')
545
+ })
546
+ })
@@ -292,10 +292,12 @@ export function normalizeForTts(text: string): string {
292
292
  return words.join(' ')
293
293
  })
294
294
 
295
- // -- Times HH:MM (24h) → spoken. The (?!:?\d) guard skips HH:MM:SS
296
- // entirely — a half-spoken time with a dangling ":46" reads worse
297
- // than leaving the digits as-is.
298
- s = s.replace(/\b([01]?\d|2[0-3]):([0-5]\d)(?!:?\d)/g, (_m, hh: string, mm: string) => {
295
+ // -- Times HH:MM (24h) → spoken. The (?<![\d:]) / (?!:?\d) guards skip
296
+ // HH:MM:SS entirely — a half-spoken time with a dangling ":46" reads
297
+ // worse than leaving the digits as-is. The LOOKBEHIND is load-bearing:
298
+ // without it the scan re-anchors INSIDE the timestamp (`09:00:00` →
299
+ // "09:zero o'clock") because the trailing `00` is itself a legal HH:MM.
300
+ s = s.replace(/(?<![\d:])\b([01]?\d|2[0-3]):([0-5]\d)(?!:?\d)/g, (_m, hh: string, mm: string) => {
299
301
  const h = Number(hh)
300
302
  const min = Number(mm)
301
303
  const hw = belowThousand(h)
@@ -29,6 +29,14 @@
29
29
  * ambiguous (strikethrough marker vs. approx) and dropping is the safest
30
30
  * choice that never mangles a real word.
31
31
  * - `->` / `=>` / `→` become the spoken word "to".
32
+ * - Block boundaries (headings, bullets, ordered items) become SENTENCE
33
+ * boundaries: the marker is replaced with terminating punctuation so a
34
+ * multi-bullet reply is spoken as separate sentences with breath between
35
+ * them, instead of the newline-collapse fusing it into one run-on.
36
+ * - A multi-segment filesystem path (`/var/log/syslog`, `~/.config/x/y`,
37
+ * `a/b/c`) is spoken as its LAST segment, or "a path" when that segment
38
+ * is unspeakable noise. A single `word/word` is prose and is left for the
39
+ * downstream "word slash word" pass in normalizeForTts.
32
40
  * - A tiny, well-tested set of trivially-safe abbreviations is expanded
33
41
  * ("e.g." → "for example", "i.e." → "that is", "etc." → "and so on",
34
42
  * "vs" → "versus", "approx" → "approximately", "w/" → "with").
@@ -237,12 +245,141 @@ const UNIT_MAP: Record<string, { s: string; p: string }> = {
237
245
  tb: { s: 'terabyte', p: 'terabytes' },
238
246
  }
239
247
 
240
- /** Curated initialisms spoken letter-by-letter. Uppercase keys only. */
248
+ /**
249
+ * Curated initialisms spoken letter-by-letter. Uppercase keys only.
250
+ *
251
+ * Only tokens in this set are ever expanded, so widening the set is safe:
252
+ * word-style acronyms (NASA, ALWAYS) still fall through untouched because the
253
+ * generic all-caps matcher consults this map before doing anything.
254
+ */
241
255
  const ACRONYMS = new Set([
242
256
  'CI', 'PR', 'API', 'URL', 'GPU', 'CPU', 'TTS', 'STT', 'HTTP', 'JSON',
243
257
  'SQL', 'UI',
258
+ // Common in agent replies; previously read as nonsense words ("hoops",
259
+ // "duh-ness", "mick-p") by the engine.
260
+ 'HTTPS', 'SSH', 'DNS', 'CLI', 'AWS', 'UTC', 'MCP', 'PDF', 'ID', 'OK',
261
+ 'VM', 'LLM', 'YAML', 'RAM', 'USB',
244
262
  ])
245
263
 
264
+ /** Longest key in ACRONYMS — the all-caps matcher's upper length bound. */
265
+ const ACRONYM_MAX_LEN = Math.max(...[...ACRONYMS].map((a) => a.length))
266
+
267
+ /**
268
+ * A filesystem-path-shaped token: two or more `/`-separated segments, with an
269
+ * optional leading segment (`a/b/c`), `~` (`~/.config/foo`) or nothing
270
+ * (`/var/log/syslog`). Requiring TWO separators is deliberate — a single
271
+ * `word/word` is prose ("and/or") and is left for the downstream
272
+ * "word slash word" pass in tts-normalize.
273
+ */
274
+ const PATH_TOKEN_RE =
275
+ /(?<![\w~./-])(?:~|\.{1,2}|[A-Za-z0-9_.@-]+)?(?:\/[A-Za-z0-9_.@+-]+){2,}\/?(?![\w/-])/g
276
+
277
+ /**
278
+ * A path-shaped token needs an ANCHOR before we may swallow it: a leading
279
+ * `/`, `./`, `../` or `~/`, or a final segment carrying a file extension.
280
+ * "Two or more slashes ⇒ path" is false in English — `yes/no/maybe`,
281
+ * `read/write/exec`, `he/she/they`, `client/server/proxy` and
282
+ * `unit/integration/e2e` are all prose, and swallowing them DELETES words
283
+ * from the reply. Anything that is only a run of ordinary lowercase
284
+ * word-shaped segments is left for the downstream "word slash word" pass.
285
+ */
286
+ function looksLikePath(m: string, segs: string[]): boolean {
287
+ if (/^(?:\/|\.{1,2}\/|~\/)/.test(m)) return true
288
+ const last = segs[segs.length - 1]!
289
+ if (/\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(last)) return true
290
+ // Every segment an ordinary lowercase word (letters, then optional digits)
291
+ // ⇒ prose, not a path.
292
+ return !segs.every((sg) => /^[a-z][a-z0-9]*$/.test(sg))
293
+ }
294
+
295
+ /** True when a path segment is worth speaking (a real name, not a blob). */
296
+ function isSpeakableSegment(seg: string): boolean {
297
+ if (!/[A-Za-z]/.test(seg)) return false
298
+ if (seg.length > 32) return false
299
+ // A hex blob (sha, uuid chunk) reads as noise; prefer "a path".
300
+ if (/^[0-9a-f]{8,}$/i.test(seg)) return false
301
+ return true
302
+ }
303
+
304
+ /**
305
+ * Speak a filesystem path as just its final segment ("/var/log/syslog" →
306
+ * "syslog"), or "a path" when that segment carries no speakable name. Reading
307
+ * a full path aloud is the worst kind of TTS noise — a long run of "slash"
308
+ * between unpronounceable fragments. An all-numeric token run (a date like
309
+ * `12/25/2026`) is explicitly NOT a path and is returned untouched.
310
+ */
311
+ function speakPaths(input: string): string {
312
+ return input.replace(PATH_TOKEN_RE, (m) => {
313
+ const segs = m.split('/').filter((sg) => sg.length > 0)
314
+ if (segs.length < 2) return m
315
+ if (segs.every((sg) => /^\d+$/.test(sg))) return m
316
+ if (!looksLikePath(m, segs)) return m
317
+ const last = segs[segs.length - 1]!
318
+ return isSpeakableSegment(last) ? last : 'a path'
319
+ })
320
+ }
321
+
322
+ /** A line whose leading markup starts a new block (heading / list item). */
323
+ const BLOCK_MARKER_RE = /^[ \t]{0,3}(?:#{1,6}[ \t]+|[-*+][ \t]+|\d+[.)][ \t]+)/
324
+ /** Heading subset — a heading never has continuation lines. */
325
+ const HEADING_MARKER_RE = /^[ \t]{0,3}#{1,6}[ \t]+/
326
+
327
+ /** Give a line sentence-terminating punctuation without doubling it. */
328
+ function ensureTerminated(line: string): string {
329
+ const t = line.replace(/[ \t]+$/, '')
330
+ if (!t.trim()) return t
331
+ // Already terminal (or a natural pause) — leave it, never emit ".." / ". .".
332
+ if (/[.!?:;]$/.test(t)) return t
333
+ // A trailing comma at a block boundary is an artifact of list formatting.
334
+ if (/,$/.test(t)) return `${t.slice(0, -1)}.`
335
+ return `${t}.`
336
+ }
337
+
338
+ /**
339
+ * Strip heading / list markers AND turn each block boundary into a sentence
340
+ * boundary. Without this the later newline-collapse joins every bullet into
341
+ * one breathless run-on sentence — the single biggest voice-pacing complaint.
342
+ *
343
+ * A wrapped list item (a continuation line carrying no marker of its own) is
344
+ * terminated only at the END of the unit, so a sentence split across two
345
+ * source lines is not chopped mid-clause. The line immediately BEFORE a block
346
+ * starts is terminated too, so "Steps" + bullets doesn't read as
347
+ * "Steps first".
348
+ */
349
+ function applyBlockPauses(input: string): string {
350
+ const lines = input.split('\n')
351
+ const isBlock = lines.map((l) => BLOCK_MARKER_RE.test(l))
352
+ const isHeading = lines.map((l) => HEADING_MARKER_RE.test(l))
353
+ const stripped = lines.map((l, i) =>
354
+ isBlock[i] ? l.slice(l.match(BLOCK_MARKER_RE)![0].length) : l,
355
+ )
356
+ // A line is "inside a block unit" if it starts one, or continues one. A
357
+ // heading owns exactly its own line — the prose under it is a new unit.
358
+ const inUnit: boolean[] = []
359
+ for (let i = 0; i < stripped.length; i++) {
360
+ inUnit[i] =
361
+ isBlock[i] === true ||
362
+ (i > 0 &&
363
+ inUnit[i - 1] === true &&
364
+ isHeading[i - 1] !== true &&
365
+ stripped[i]!.trim() !== '')
366
+ }
367
+ return stripped
368
+ .map((line, i) => {
369
+ const next = stripped[i + 1]
370
+ const nextIsBlock = isBlock[i + 1] === true
371
+ const unitEndsHere =
372
+ isHeading[i] === true ||
373
+ next === undefined ||
374
+ next.trim() === '' ||
375
+ nextIsBlock
376
+ if (inUnit[i] && unitEndsHere) return ensureTerminated(line)
377
+ if (nextIsBlock && line.trim() !== '') return ensureTerminated(line)
378
+ return line
379
+ })
380
+ .join('\n')
381
+ }
382
+
246
383
  /**
247
384
  * Convert a Markdown/plain reply into clean text for a TTS engine.
248
385
  * Pure and deterministic — same input always yields the same output.
@@ -277,7 +414,12 @@ export function normalizeForSpeech(input: string): string {
277
414
  /[\u{1F000}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{200D}\u{2B50}\u{3030}\u{303D}\u{3297}\u{3299}\u{24C2}]/gu,
278
415
  '',
279
416
  )
280
- s = s.replace(/:([a-z0-9][a-z0-9_+-]*):/gi, ' ')
417
+ // Digit-bodied shortcodes are real (`:100:`, `:8ball:`,
418
+ // `:1st_place_medal:`), so the body may start with a digit — the
419
+ // timestamp protection is positional instead: the opening colon may not
420
+ // follow a digit/colon and the closing colon may not precede a digit, so
421
+ // the pass can never eat the colons out of `14:30:46`.
422
+ s = s.replace(/(?<![\w:]):([a-z0-9][a-z0-9_+-]*):(?!\d)/gi, ' ')
281
423
 
282
424
  // 1. Fenced code blocks first (```lang … ``` or ~~~ … ~~~) — drop the
283
425
  // whole block before any inline processing can see its contents.
@@ -301,6 +443,11 @@ export function normalizeForSpeech(input: string): string {
301
443
  // Any residual backticks → drop.
302
444
  s = s.replace(/`/g, '')
303
445
 
446
+ // 5b. Filesystem paths → their last segment. Runs before the block/symbol
447
+ // passes (and before the downstream "word slash word" pass in
448
+ // normalizeForTts) so a path is never spelled out slash-by-slash.
449
+ s = speakPaths(s)
450
+
304
451
  // 6. Emphasis markers. Paired forms first (longest marker first), then
305
452
  // strip residual markup-by-construction doubles. A LONE `*` or `_` in
306
453
  // the middle of maths/words is left alone (see step 11).
@@ -316,10 +463,8 @@ export function normalizeForSpeech(input: string): string {
316
463
  // 7. Leading block markup, per line: headings, blockquotes, list markers.
317
464
  // List bullets/numbers become a natural sentence pause rather than a
318
465
  // spoken "dash" / "1 dot".
319
- s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
320
466
  s = s.replace(/^[ \t]{0,3}>[ \t]?/gm, '')
321
- s = s.replace(/^[ \t]{0,3}[-*+][ \t]+/gm, '')
322
- s = s.replace(/^[ \t]{0,3}\d+[.)][ \t]+/gm, '')
467
+ s = applyBlockPauses(s)
323
468
 
324
469
  // 8. Horizontal rules (---, ___, ***) on their own line → drop.
325
470
  s = s.replace(/^[ \t]{0,3}([-_*])\1{2,}[ \t]*$/gm, '')
@@ -360,7 +505,12 @@ export function normalizeForSpeech(input: string): string {
360
505
  })
361
506
  // Clock time HH:MM (24h ok) → spoken. Guarded by word boundaries so a
362
507
  // ratio like "3:2" or a bare number isn't caught (needs 2-digit MM).
363
- s = s.replace(/\b([01]?\d|2[0-3]):([0-5]\d)\b/g, (m, hh, mm) => {
508
+ // The (?<![\d:]) / (?!:?\d) guards skip HH:MM:SS entirely (parity with
509
+ // normalizeForTts) — a half-spoken time with a dangling ":46" reads
510
+ // worse than leaving the digits as-is. The LOOKBEHIND is load-bearing:
511
+ // without it the scan re-anchors INSIDE the timestamp (`09:00:00` →
512
+ // "09:zero o'clock") because the trailing `00` is itself a legal HH:MM.
513
+ s = s.replace(/(?<![\d:])\b([01]?\d|2[0-3]):([0-5]\d)(?!:?\d)/g, (m, hh, mm) => {
364
514
  const h = Number(hh)
365
515
  const min = Number(mm)
366
516
  const hw = belowThousand(h)
@@ -370,11 +520,18 @@ export function normalizeForSpeech(input: string): string {
370
520
 
371
521
  // 14. Numbers, units & symbols → spoken words. Each sub-pass is guarded so
372
522
  // it only fires on a clear number+token, never mid-word.
373
- // Currency: $5 / $5.50 → "five dollars" / "five dollars fifty".
374
- s = s.replace(/\$(\d{1,9})(?:\.(\d{2}))?\b/g, (m, dollars, cents) => {
375
- const dw = numberToWords(Number(dollars))
523
+ // Currency: $5 / $5.50 → "five dollars" / "five dollars fifty";
524
+ // $1,000 → "one thousand dollars" (thousands separators consumed, so
525
+ // the old "one dollar,000" misreading is impossible). The trailing
526
+ // lookahead bails on odd cents ("$5.203") and partial thousands
527
+ // ("$1,00") — the whole token is left unchanged rather than half-read.
528
+ // The guard must NOT fire on an ordinary sentence comma ("$500, plus
529
+ // tax") — only on a comma/period that STARTS another digit group.
530
+ s = s.replace(/\$(\d{1,3}(?:,\d{3})+|\d{1,9})(?:\.(\d{2}))?(?!\d|[.,]\d)/g, (m, dollarsRaw, cents) => {
531
+ const dollars = Number(String(dollarsRaw).replace(/,/g, ''))
532
+ const dw = numberToWords(dollars)
376
533
  if (!dw) return m
377
- const noun = Number(dollars) === 1 && !cents ? 'dollar' : 'dollars'
534
+ const noun = dollars === 1 && !cents ? 'dollar' : 'dollars'
378
535
  if (cents && cents !== '00') {
379
536
  const cw = numberToWords(Number(cents))
380
537
  return `${dw} ${noun} ${cw}`
@@ -419,7 +576,7 @@ export function normalizeForSpeech(input: string): string {
419
576
  // 15. Acronyms → letter-by-letter for a curated set of initialisms. Only a
420
577
  // standalone all-caps token that exactly matches the map is expanded;
421
578
  // word-style acronyms (NASA) and sub-tokens of larger words are left.
422
- s = s.replace(/\b[A-Z]{2,5}\b/g, (tok) =>
579
+ s = s.replace(new RegExp(`\\b[A-Z]{2,${ACRONYM_MAX_LEN}}\\b`, 'g'), (tok) =>
423
580
  ACRONYMS.has(tok) ? tok.split('').join(' ') : tok,
424
581
  )
425
582
 
@@ -4,6 +4,79 @@
4
4
 
5
5
  ### Changed (switchroom divergence)
6
6
 
7
+ - **`MAX_DIRECTIVES` 15 → 30, and truncation is no longer SILENT**
8
+ (`scripts/lib/directives.py`). Live fleet active-directive counts were 24
9
+ (assistant), 17 (klanker), 15 (carrie) against a client-side cap of 15, so the
10
+ busiest bank had 9 of its hard rules dropped from every turn's prompt with no
11
+ signal anywhere: the `(+N more, omitted)` footer only tells the AGENT. 30
12
+ clears the observed fleet maximum with headroom while staying bounded (the
13
+ block is injected on EVERY turn — this is a per-turn token cost, not a free
14
+ knob; the constant is commented as such). `format_active_directives_block`
15
+ now also prints a `[Hindsight] directive truncation: …` warn line to stderr
16
+ whenever it drops directives, and `recall.py` records the dropped count as
17
+ `directives_omitted` on the recall_log row.
18
+
19
+ Visibility correction (2026-07-25 review): hook stderr is NOT an operator
20
+ channel. `docker logs --tail 20000` across all 12 live agent containers
21
+ returns ZERO `[Hindsight]` lines, and nothing under `~/.switchroom/logs/`
22
+ contains them either, despite months of runtime and several long-standing
23
+ stderr paths in `recall.py` — Claude Code swallows hook stderr on a zero
24
+ exit. The stderr line is kept as a last-resort breadcrumb; the channels that
25
+ actually reach an operator are the `directives_omitted` recall_log field and
26
+ `switchroom doctor`'s directive-count row. Paired switchroom-side:
27
+ `src/cli/doctor-memory.ts` `MAX_DIRECTIVES` 15 → 30 and
28
+ `DIRECTIVE_WARN_THRESHOLD` 12 → 24 (a drift-guard test pins the TS constant
29
+ to the Python one). The `MAX_DIRECTIVES` cost comment now states the real
30
+ mechanism (rebuilt every `UserPromptSubmit`, appended into the conversation,
31
+ so cost is per-turn CUMULATIVE) with the measured live figures. Acceptance:
32
+ `scripts/tests/test_directives.py`
33
+ (`test_cap_is_30_and_clears_the_observed_fleet_maximum`,
34
+ `test_truncation_emits_a_stderr_breadcrumb_naming_the_dropped_count`,
35
+ `test_count_omitted_directives_matches_the_rendered_footer`).
36
+
37
+ - **`retainMission` rewritten with explicit, enumerated exclusions**
38
+ (`settings.json`). The extraction model is a small local `gpt-oss-20b`, and
39
+ the previous one-line "Ignore routine greetings and transient operational
40
+ details" did not hold: production banks contain pure transcript traces
41
+ ("The assistant used ToolSearch to query for hindsight bank statistics"),
42
+ hindsight's own batch failures with the UUID inline, restatements of the
43
+ then-current prompt, and undated transient state ("User has no unread mail",
44
+ which then recalls forever as a standing fact). The new mission enumerates
45
+ those noise classes as NEVER-extract bullets and adds a positive
46
+ counterweight ("a preference revealed by a request is durable") — without it,
47
+ an exclusion-only mission made the model return a degenerate/empty response
48
+ on chatty-but-real turns in a 6-window live sample. The text is pinned
49
+ byte-for-byte to switchroom's `DEFAULT_RETAIN_MISSION`
50
+ (`src/memory/hindsight.ts`) by a drift guard, because BOTH reach the same
51
+ extraction step: switchroom seeds the bank-side mission at scaffold, and the
52
+ plugin independently pushes this one via `lib/bank.py: ensure_bank_mission`
53
+ on a fresh state dir. Before this change the two texts differed.
54
+
55
+ One 2026-07-25 review correction folded in, itself corrected by the
56
+ re-review: the rewrite DID reach existing agents, but unsafely.
57
+ `ensure_bank_mission` short-circuits on the already-seeded flag in
58
+ `bank_missions.json`, so the plugin was never the propagation path — but
59
+ `switchroom apply` re-scaffolds every agent, and scaffold pushed
60
+ `retain_mission` unconditionally on every run. That is why all 24 live banks
61
+ carried the 2026-07-19 text even though no agent sets `retain_mission` in
62
+ yaml. The hazard was the unconditional overwrite, not a stuck mission.
63
+ Switchroom now routes BOTH of its bank-op sites (scaffold and
64
+ `reconcileAgent`) through `decideRetainMissionUpgrade`: the mission upgrades
65
+ only when the bank's current text byte-equals a known previous default
66
+ (`SUPERSEDED_RETAIN_MISSIONS`) or is unset, so a customized mission matches
67
+ nothing and is never clobbered.
68
+
69
+ A second proposed correction — narrowing the "Greetings, acknowledgements,
70
+ and routine operational chatter" bullet and adding a personal-preference
71
+ clause — was written and then REVERTED. Sampling did not reproduce the
72
+ preference loss it was meant to fix, and the narrowed mission extracted MORE
73
+ noise than both this text and the pre-PR default on a real operational
74
+ window (8 facts vs 0 vs 4, including in-flight worker narration its own
75
+ bullet forbids). The sampling method is also n=1-unreliable: identical input
76
+ under the identical narrowed mission gave 0, 6, 6. No extraction-quality
77
+ claim is made here in either direction; the mission-content question is
78
+ deferred to switchroom#3532 (profile-scoped retain missions).
79
+
7
80
  - **`recallContextTurns` default `1` → `2`** (switchroom hindsight-leverage
8
81
  PR2, workstream A2). A bare follow-up user message ("and the port?", "what
9
82
  about staging?") now embeds together with its antecedent human turn in the
@@ -29,15 +29,20 @@ DEFAULTS = {
29
29
  # formatting. Set to 0 (or any non-positive value) to disable the cap
30
30
  # and inject everything Hindsight returns.
31
31
  "recallMaxMemories": 12,
32
- # Switchroom-local: minimum lexical (Jaccard) overlap between the
32
+ # Switchroom-local: minimum lexical (containment) overlap between the
33
33
  # user's query terms and a memory's text terms. Memories below this
34
34
  # threshold are dropped before formatting. 0.0 disables the gate
35
35
  # (current behaviour: inject everything Hindsight returns up to the
36
36
  # count cap). NOTE: Hindsight's HTTP recall API DOES return per-result
37
37
  # relevance scores (`scores.final`, plus `.semantic`/`.keyword`/
38
38
  # `.reranker`) — verified at runtime — and recall.py now reads and
39
- # sorts the merged set by `scores.final`. This Jaccard gate is a
40
- # separate lexical-overlap quality filter layered on top — see #475.
39
+ # sorts the merged set by `scores.final`. This lexical gate is a
40
+ # separate quality filter layered on top — see #475. The metric is
41
+ # containment, `|Q n M| / |M|`, not Jaccard: dividing by the union made
42
+ # the score a function of prompt length rather than relevance — see
43
+ # #3541 and recall.py's design note. At the 0.10 fleet default this is
44
+ # close to a passthrough (a <=10-token memory clears it on one shared
45
+ # word); precision is the engine rerank's job, not this gate's.
41
46
  "recallMinOverlap": 0.0,
42
47
  "recallTypes": ["world", "experience"],
43
48
  # Switchroom-local: when True (default; Ken-approved ON) recall biases