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.
- package/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +13 -4
- package/dist/auth-broker/index.js +32 -5
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +13 -4
- package/dist/cli/switchroom.js +10614 -8104
- package/dist/host-control/main.js +12849 -11446
- package/dist/vault/approvals/kernel-server.js +90 -12
- package/dist/vault/broker/server.js +277 -94
- package/package.json +5 -3
- package/profiles/_base/start.sh.hbs +69 -5
- 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 +25 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +28 -20
- package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
- package/telegram-plugin/dist/server.js +32 -20
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- 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/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- 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/bridge-tool-parity.test.ts +95 -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/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/telegram-plugin/worker-activity-feed.ts +51 -1
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -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/lib/pending.py +865 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
- 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
|
-
/**
|
|
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
|
|
|
@@ -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.
|
|
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
|