switchroom 0.19.17 โ†’ 0.19.19

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 (91) 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 +13 -4
  4. package/dist/auth-broker/index.js +32 -5
  5. package/dist/cli/drive-write-pretool.mjs +48 -5
  6. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  7. package/dist/cli/notion-write-pretool.mjs +13 -4
  8. package/dist/cli/switchroom.js +10614 -8104
  9. package/dist/host-control/main.js +12849 -11446
  10. package/dist/vault/approvals/kernel-server.js +90 -12
  11. package/dist/vault/broker/server.js +277 -94
  12. package/package.json +5 -3
  13. package/profiles/_base/start.sh.hbs +69 -5
  14. package/profiles/coding/CLAUDE.md.hbs +1 -1
  15. package/profiles/default/CLAUDE.md.hbs +3 -3
  16. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  17. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  18. package/skills/mental-model-curator/SKILL.md +8 -6
  19. package/telegram-plugin/bridge/bridge.ts +25 -19
  20. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +28 -20
  22. package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
  23. package/telegram-plugin/dist/server.js +32 -20
  24. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  25. package/telegram-plugin/gateway/boot-card.ts +5 -1
  26. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  27. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  28. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  30. package/telegram-plugin/gateway/gateway.ts +43 -1
  31. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  32. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  33. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  34. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  35. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  36. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  37. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  38. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  39. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  40. package/telegram-plugin/gateway/store-file.ts +244 -0
  41. package/telegram-plugin/gateway/stream-render.ts +24 -5
  42. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  43. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  44. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  45. package/telegram-plugin/registry/turns-schema.ts +40 -12
  46. package/telegram-plugin/runtime-metrics.ts +14 -0
  47. package/telegram-plugin/silence-poke.ts +138 -0
  48. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  49. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  51. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  52. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  53. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  54. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  56. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  57. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  58. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  59. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  60. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  61. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  62. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  63. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  64. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  66. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  67. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  68. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  69. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  70. package/telegram-plugin/tts-normalize.ts +6 -4
  71. package/telegram-plugin/voice-normalize-text.ts +168 -11
  72. package/telegram-plugin/worker-activity-feed.ts +51 -1
  73. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  74. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  75. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  76. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  77. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  78. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  79. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  81. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  82. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  84. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  85. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  87. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  89. package/vendor/hindsight-memory/settings.json +1 -1
  90. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  91. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -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
 
@@ -142,6 +142,25 @@ export interface BotApiForWorkerFeed {
142
142
  /** Dispatch-time task description cap for the worker header. */
143
143
  const DESC_MAX = 80
144
144
 
145
+ /**
146
+ * Repeat marker appended to a narrative step that fired more than once in a
147
+ * row (`Running a command ยทร—3`). Rendered inline on the step line so a worker
148
+ * whose every step carries the SAME label still visibly advances instead of
149
+ * freezing the card. `ยท` is already the card's separator glyph.
150
+ */
151
+ const REPEAT_SUFFIX_RE = / ยทร—(\d+)$/
152
+
153
+ /** The step text without its `ยทร—N` marker (identity for dedup comparisons). */
154
+ export function stripRepeatSuffix(line: string): string {
155
+ return line.replace(REPEAT_SUFFIX_RE, '')
156
+ }
157
+
158
+ /** How many times a step line has fired: 1 when it carries no marker. */
159
+ export function repeatCountOf(line: string): number {
160
+ const m = REPEAT_SUFFIX_RE.exec(line)
161
+ return m == null ? 1 : Number(m[1])
162
+ }
163
+
145
164
  /**
146
165
  * Thin adapter over the unified `renderStatusCard` primitive (emoji ๐Ÿ› , label
147
166
  * 'Worker'): builds the header, passes raw narrative steps (the primitive runs
@@ -428,6 +447,13 @@ interface WorkerRow {
428
447
  * live render so the feed reads like the main agent's answer.
429
448
  */
430
449
  narrative: string[]
450
+ /**
451
+ * `view.toolCount` observed when the newest narrative step was last recorded
452
+ * or counted. The repeat counter (`ยทร—N`) increments only when toolCount has
453
+ * MOVED โ€” the watcher re-emits an unchanged view every tick, so counting
454
+ * label-equality alone would inflate the number with no work behind it.
455
+ */
456
+ lastNarrativeToolCount: number | null
431
457
  /** Last view for this worker (drives the heartbeat re-render + combined row). */
432
458
  lastView: WorkerActivityView | null
433
459
  /** Latest state observed for this worker; excluded from the running set once
@@ -794,10 +820,33 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
794
820
  function accumulateNarrative(row: WorkerRow, view: WorkerActivityView): void {
795
821
  const line = view.latestSummary.trim()
796
822
  if (line.length === 0) return
823
+
824
+ // A REPEAT of the current step is counted, not dropped. A worker whose
825
+ // steps all render the same label (e.g. Bash calls with no description โ†’
826
+ // "Running a command") used to hit the dedup below on every tool call and
827
+ // the card held ONE frozen line for the whole job โ€” indistinguishable from
828
+ // a wedged worker. `ยทร—N` makes the repetition visible and, because a new
829
+ // count resets stepStartedAtMs, the climbing `ยท Ns` step timer restarts
830
+ // with each real call.
831
+ //
832
+ // Gated on `view.toolCount`, NOT on the label: the watcher re-emits an
833
+ // UNCHANGED view on every tick, so counting label-equality alone would
834
+ // inflate the number with no work behind it. One increment per observed
835
+ // tool call, deterministically.
836
+ const last = row.narrative.length > 0 ? row.narrative[row.narrative.length - 1] : null
837
+ if (last != null && stripRepeatSuffix(last) === line) {
838
+ if (view.toolCount === row.lastNarrativeToolCount) return
839
+ row.lastNarrativeToolCount = view.toolCount
840
+ row.narrative[row.narrative.length - 1] = `${line} ยทร—${repeatCountOf(last) + 1}`
841
+ row.stepStartedAtMs = nowFn()
842
+ return
843
+ }
844
+
797
845
  // Dedup within the whole rolling window (the watcher re-emits the same
798
846
  // narrative across ticks, and a preamble + its tool label can repeat
799
847
  // non-adjacently โ€” the A,B,A duplication observed on live cards).
800
- if (row.narrative.includes(line)) return
848
+ if (row.narrative.some((l) => stripRepeatSuffix(l) === line)) return
849
+ row.lastNarrativeToolCount = view.toolCount
801
850
  row.narrative.push(line)
802
851
  // The `โ†’` current-step line just CHANGED โ€” reset the per-step timer.
803
852
  row.stepStartedAtMs = nowFn()
@@ -1545,6 +1594,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1545
1594
  agentId,
1546
1595
  ordinal: ++g.workerOrdinalCounter,
1547
1596
  narrative: [],
1597
+ lastNarrativeToolCount: null,
1548
1598
  lastView: null,
1549
1599
  state: 'running',
1550
1600
  finished: false,
@@ -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