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.
- package/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +11 -3
- package/dist/auth-broker/index.js +29 -4
- package/dist/cli/notion-write-pretool.mjs +11 -3
- package/dist/cli/switchroom.js +8307 -7620
- package/dist/host-control/main.js +626 -36
- package/dist/vault/approvals/kernel-server.js +30 -5
- package/dist/vault/broker/server.js +71 -18
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +8 -4
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +11 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +15 -20
- package/telegram-plugin/dist/gateway/gateway.js +763 -373
- package/telegram-plugin/dist/server.js +19 -20
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- 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
|
-
|
|
34
|
-
|
|
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)
|
|
296
|
-
// entirely — a half-spoken time with a dangling ":46" reads
|
|
297
|
-
// than leaving the digits as-is.
|
|
298
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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 =
|
|
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(
|
|
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 (
|
|
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
|
|
40
|
-
# separate
|
|
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
|