switchroom 0.18.19 → 0.18.21
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/dist/cli/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -480,14 +480,167 @@ export function hardenCardBreaks(text: string): string {
|
|
|
480
480
|
}
|
|
481
481
|
|
|
482
482
|
// ---------------------------------------------------------------------------
|
|
483
|
-
// Paragraph
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
483
|
+
// Paragraph spacers — restore a VISIBLE blank line between prose paragraphs
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The non-collapsible spacer paragraph injected between two prose paragraphs.
|
|
488
|
+
*
|
|
489
|
+
* Telegram's Bot API 10.1 rich-message renderer (the GFM/CommonMark engine
|
|
490
|
+
* behind `sendRichMessage` / `editMessageText({ markdown })`, and the in-repo
|
|
491
|
+
* IR renderer in `render/` that feeds it) renders a `\n\n` paragraph break
|
|
492
|
+
* TIGHT — the two paragraphs sit on adjacent lines with no visible empty line
|
|
493
|
+
* between them (live-confirmed: an outbound message with real `\n\n` gaps
|
|
494
|
+
* renders jammed; only list/table BLOCK boundaries produce a visible gap). The
|
|
495
|
+
* legacy markdown→HTML path (removed in #2669) sent `\n\n` literally with
|
|
496
|
+
* `parse_mode:"HTML"`, where two newlines render as a real blank line. That
|
|
497
|
+
* regression is the operator-confirmed "paragraphs jammed together" symptom
|
|
498
|
+
* (#2692).
|
|
499
|
+
*
|
|
500
|
+
* CommonMark discards blank lines made of ASCII whitespace, but a line whose
|
|
501
|
+
* only content is a NON-breaking space (U+00A0) is a genuine, non-empty
|
|
502
|
+
* paragraph — it renders as a visible empty line. So `A\n\n \n\nB` renders as
|
|
503
|
+
* three paragraphs: A, a blank-looking line, then B — the visible gap the HTML
|
|
504
|
+
* path used to produce. This survives the in-repo IR renderer's re-parse /
|
|
505
|
+
* re-render (verified: a U+00A0-only line round-trips intact, while an
|
|
506
|
+
* ASCII-space-only line is collapsed to a tight `\n\n`), so it reaches the wire
|
|
507
|
+
* whether `SWITCHROOM_RICH_RENDER` is on (default) or off.
|
|
508
|
+
*/
|
|
509
|
+
export const PARAGRAPH_SPACER = ' '
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Insert a visible blank-line spacer into each genuine `\n\n` paragraph gap so
|
|
513
|
+
* the rich GFM renderer shows a real empty line between paragraphs (matching
|
|
514
|
+
* the pre-#2669 HTML behaviour). See PARAGRAPH_SPACER for why a U+00A0 line is
|
|
515
|
+
* the reliable trick.
|
|
516
|
+
*
|
|
517
|
+
* Uniform-block-spacing contract: a spacer is inserted into EVERY `\n\n` gap
|
|
518
|
+
* that separates two DISTINCT blocks — prose→prose, paragraph→list,
|
|
519
|
+
* list→paragraph, heading→anything, blockquote/table/fence boundaries — so a
|
|
520
|
+
* mixed message renders with one identical visible blank line between blocks.
|
|
521
|
+
* The one exception is a gap INSIDE a block of the same structural kind (two
|
|
522
|
+
* items of a loose list, consecutive table rows/quotes/fences): those stay
|
|
523
|
+
* tight so the block's contiguity survives. Interiors joined by a single `\n`
|
|
524
|
+
* are never gaps at all and are untouched by construction.
|
|
525
|
+
*
|
|
526
|
+
* Runs on code-masked text (so a blank line inside a fenced block is never
|
|
527
|
+
* touched).
|
|
528
|
+
*
|
|
529
|
+
* IDEMPOTENT & DOUBLE-GAP-PROOF (the #3208 regression this restores fixes):
|
|
530
|
+
* each inter-block gap — whatever it originally holds (a bare `\n\n`, a
|
|
531
|
+
* pre-existing U+00A0 spacer `\n\n \n\n`, a stray extra blank line `\n\n\n`, an
|
|
532
|
+
* ASCII-space-only line) — is CANONICALISED to exactly one form: `\n\n`
|
|
533
|
+
* (tight) or `\n\n${PARAGRAPH_SPACER}\n\n` (spaced). It therefore inserts
|
|
534
|
+
* EXACTLY ONE spacer per spaced gap and can never stack a second, and running
|
|
535
|
+
* the pass twice yields the same output as running it once. #3208 removed the
|
|
536
|
+
* whole mechanism to kill a double-gap; the correct fix was to make the gap
|
|
537
|
+
* canonical (this), not to delete the spacer and reintroduce the jammed-
|
|
538
|
+
* paragraph symptom fleet-wide.
|
|
539
|
+
*
|
|
540
|
+
* Intended to run in the outbound send path AFTER normalizeParagraphBreaks
|
|
541
|
+
* (which has already collapsed 3+ newline runs to `\n\n` and guaranteed
|
|
542
|
+
* block-boundary blank lines) — but the canonicalisation above means it is
|
|
543
|
+
* safe on un-normalized text too (e.g. the edit path).
|
|
544
|
+
*/
|
|
545
|
+
export function addParagraphSpacers(text: string): string {
|
|
546
|
+
if (!text.includes('\n\n')) return text
|
|
547
|
+
|
|
548
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
549
|
+
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
550
|
+
|
|
551
|
+
if (!masked.includes('\n\n')) return restore(masked)
|
|
552
|
+
|
|
553
|
+
const SP = PARAGRAPH_SPACER
|
|
554
|
+
|
|
555
|
+
// ASCII-only trim (preserve U+00A0): `String.prototype.trim()` strips U+00A0,
|
|
556
|
+
// so a spacer line would read as "blank"; trimming ASCII whitespace only
|
|
557
|
+
// keeps the spacer line recognisable as content.
|
|
558
|
+
const asciiTrim = (line: string): string =>
|
|
559
|
+
line.replace(/^[ \t\r\f\v]+|[ \t\r\f\v]+$/g, '')
|
|
560
|
+
|
|
561
|
+
// "Blank-ish" = a line that renders as an empty paragraph: ASCII-whitespace-
|
|
562
|
+
// only, OR a line whose only non-ASCII-whitespace content is the U+00A0
|
|
563
|
+
// spacer. Coalescing BOTH kinds into one gap is what makes the pass
|
|
564
|
+
// idempotent and double-gap-proof — a pre-existing spacer or extra blank line
|
|
565
|
+
// is absorbed into the gap and re-emitted canonically, never stacked.
|
|
566
|
+
const isBlankish = (line: string): boolean => {
|
|
567
|
+
const t = asciiTrim(line)
|
|
568
|
+
return t === '' || t === SP
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Classify the block kind of a facing content line so the spacer decision can
|
|
572
|
+
// be made per BLOCK TRANSITION (#uniform-block-spacing). A spacer is inserted
|
|
573
|
+
// at every gap between two DIFFERENT block kinds and between two prose
|
|
574
|
+
// paragraphs, but NEVER inside a single block's interior (two items of the
|
|
575
|
+
// same loose list, two rows of a table, consecutive quotes/fences/dividers).
|
|
576
|
+
type BlockKind = 'list' | 'table' | 'quote' | 'heading' | 'fence' | 'divider' | 'prose'
|
|
577
|
+
const blockKind = (line: string): BlockKind => {
|
|
578
|
+
if (isFenceOpenLine(line, placeholder)) return 'fence'
|
|
579
|
+
if (isListItemLine(line)) return 'list'
|
|
580
|
+
if (isTableRowLine(line) || isTableDelimiterLine(line)) return 'table'
|
|
581
|
+
if (isBlockquoteLine(line)) return 'quote'
|
|
582
|
+
if (isHeadingLine(line)) return 'heading'
|
|
583
|
+
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line.trimStart())) return 'divider'
|
|
584
|
+
return 'prose'
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Same-kind structural pairs whose gap is a block INTERIOR — no spacer there.
|
|
588
|
+
const SAME_KIND_TIGHT: ReadonlySet<BlockKind> = new Set([
|
|
589
|
+
'list',
|
|
590
|
+
'table',
|
|
591
|
+
'quote',
|
|
592
|
+
'fence',
|
|
593
|
+
'divider',
|
|
594
|
+
])
|
|
595
|
+
|
|
596
|
+
const shouldSpaceGap = (above: string, below: string): boolean => {
|
|
597
|
+
const a = blockKind(above)
|
|
598
|
+
const b = blockKind(below)
|
|
599
|
+
if (a === b && SAME_KIND_TIGHT.has(a)) return false
|
|
600
|
+
// Everything else is a genuine block transition (incl. prose→prose,
|
|
601
|
+
// heading→anything, list↔paragraph, table/quote boundaries) — space it.
|
|
602
|
+
return true
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Tokenize into CONTENT runs (consecutive non-blank-ish lines, joined by a
|
|
606
|
+
// single `\n` — soft breaks stay inside a content block) and GAP runs
|
|
607
|
+
// (consecutive blank-ish lines). Tokens strictly alternate by construction.
|
|
608
|
+
type Tok = { kind: 'content' | 'gap'; lines: string[] }
|
|
609
|
+
const toks: Tok[] = []
|
|
610
|
+
for (const line of masked.split('\n')) {
|
|
611
|
+
const kind = isBlankish(line) ? 'gap' : 'content'
|
|
612
|
+
const last = toks[toks.length - 1]
|
|
613
|
+
if (last && last.kind === kind) last.lines.push(line)
|
|
614
|
+
else toks.push({ kind, lines: [line] })
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Rebuild. A CONTENT token re-emits its lines verbatim. An INTER-content GAP
|
|
618
|
+
// (a gap flanked by content on BOTH sides) is re-emitted in canonical form —
|
|
619
|
+
// one blank line (`''` → `\n\n`) for a tight gap, or `['', SP, '']`
|
|
620
|
+
// (→ `\n\n${SP}\n\n`) for a spaced gap — discarding whatever it held. A
|
|
621
|
+
// leading/trailing gap (no content on one side, only possible before the
|
|
622
|
+
// first / after the last content block) is preserved verbatim.
|
|
623
|
+
const out: string[] = []
|
|
624
|
+
for (let i = 0; i < toks.length; i++) {
|
|
625
|
+
const tok = toks[i]
|
|
626
|
+
if (tok.kind === 'content') {
|
|
627
|
+
out.push(...tok.lines)
|
|
628
|
+
continue
|
|
629
|
+
}
|
|
630
|
+
const prev = toks[i - 1]
|
|
631
|
+
const next = toks[i + 1]
|
|
632
|
+
if (prev?.kind === 'content' && next?.kind === 'content') {
|
|
633
|
+
const above = prev.lines[prev.lines.length - 1]
|
|
634
|
+
const below = next.lines[0]
|
|
635
|
+
if (shouldSpaceGap(above, below)) out.push('', SP, '')
|
|
636
|
+
else out.push('')
|
|
637
|
+
} else {
|
|
638
|
+
out.push(...tok.lines)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return restore(out.join('\n'))
|
|
643
|
+
}
|
|
491
644
|
|
|
492
645
|
// ---------------------------------------------------------------------------
|
|
493
646
|
// Punctuation / bullet normalization — fleet-wide consistent typography
|
|
@@ -1006,26 +1159,32 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
|
|
|
1006
1159
|
}
|
|
1007
1160
|
|
|
1008
1161
|
/**
|
|
1009
|
-
* Strip stray blank lines off a chunk boundary so a cut that
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1162
|
+
* Strip stray paragraph-spacer / blank lines off a chunk boundary so a cut that
|
|
1163
|
+
* lands inside an injected spacer gap (`\n\n${PARAGRAPH_SPACER}\n\n`, see
|
|
1164
|
+
* addParagraphSpacers) never leaves a continuation chunk that OPENS with a bare
|
|
1165
|
+
* U+00A0 spacer line, nor a prior chunk that ENDS with one.
|
|
1012
1166
|
*
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1167
|
+
* A "boundary blank run" is any sequence of newlines and spacer-only lines (a
|
|
1168
|
+
* line whose only content is the U+00A0 spacer, optionally surrounded by ASCII
|
|
1169
|
+
* spaces/tabs) in ANY interleaving — `\n \n`, ` \n\n`, `\n\n \n`, etc. The
|
|
1170
|
+
* legacy behaviour (strip leading ASCII `\n+` only) is a strict subset, so a
|
|
1171
|
+
* boundary with NO spacer is unaffected. Idempotent: a chunk already trimmed
|
|
1172
|
+
* has nothing left to strip.
|
|
1018
1173
|
*
|
|
1019
1174
|
* - `'leading'` → strip the run from the START (the continuation chunk).
|
|
1020
1175
|
* - `'trailing'` → strip the run from the END (the just-emitted prior chunk).
|
|
1021
1176
|
*/
|
|
1022
1177
|
function stripBoundarySpacers(chunk: string, side: 'leading' | 'trailing'): string {
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1178
|
+
// One blank-or-spacer line: optional ASCII ws, optional one U+00A0, optional
|
|
1179
|
+
// ASCII ws — i.e. a line that renders empty. A run of these (joined by \n,
|
|
1180
|
+
// with leading/trailing \n) is what we peel off the boundary.
|
|
1181
|
+
const sp = PARAGRAPH_SPACER
|
|
1025
1182
|
if (side === 'leading') {
|
|
1026
|
-
|
|
1183
|
+
// Leading: one-or-more newlines, optionally with spacer-only lines mixed in.
|
|
1184
|
+
return chunk.replace(new RegExp(`^(?:[ \\t]*${sp}?[ \\t]*\\n+)+`), '')
|
|
1027
1185
|
}
|
|
1028
|
-
|
|
1186
|
+
// Trailing: a newline run, optionally with spacer-only lines, at the very end.
|
|
1187
|
+
return chunk.replace(new RegExp(`(?:\\n+[ \\t]*${sp}?[ \\t]*)+$`), '')
|
|
1029
1188
|
}
|
|
1030
1189
|
|
|
1031
1190
|
/**
|
|
@@ -35,6 +35,38 @@ export function baseAgent(name: string): string {
|
|
|
35
35
|
return isCronIdentity(name) ? name.slice(0, -CRON_IDENTITY_SUFFIX.length) : name;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* True iff an inject_inbound fire is a scheduled cron fire — Tier-1 cheap-cron
|
|
40
|
+
* (`meta.session='cron'`, routed to the derived `<agent>-cron` bridge) OR a
|
|
41
|
+
* Tier-2 full-session cron (`meta.source='cron'`, lands on the main bridge).
|
|
42
|
+
*
|
|
43
|
+
* #3114 — such a fire must NOT stamp the MAIN session's idle-clear clock at
|
|
44
|
+
* inject time. Before #3113, `onInjectInbound` stamped unconditionally so a
|
|
45
|
+
* "working scheduled agent isn't wiped after 3h of no inbound". That is now
|
|
46
|
+
* redundant AND harmful: a cron cadence shorter than `idle_clear_after`
|
|
47
|
+
* re-arms the timer on every fire and keeps idle-clear permanently suppressed
|
|
48
|
+
* for that agent. After #3113 a cron fire that does REAL work already stamps
|
|
49
|
+
* the main clock through `handleSessionEvent` on every genuine session event,
|
|
50
|
+
* so the blanket inject-time stamp buys nothing for main-bridge fires — and a
|
|
51
|
+
* cheap-cron fire (whose session events are dropped for the cron identity in
|
|
52
|
+
* `onSessionEvent`) correctly stops warming the main clock once it is gone.
|
|
53
|
+
*
|
|
54
|
+
* Only cron fires are gated: other synthetic-source injects (reaction, vault
|
|
55
|
+
* grant, resume) reflect genuine operator/session presence and still stamp.
|
|
56
|
+
*
|
|
57
|
+
* DOCUMENTED RESIDUAL (#3114, operator-approved): a Tier-2 cron pinned to the
|
|
58
|
+
* MAIN session (`context:'agent'`) that replies NO_REPLY still runs a real
|
|
59
|
+
* turn on the main bridge, which emits session events → stamps via
|
|
60
|
+
* `handleSessionEvent`. So "a NO_REPLY poll isn't presence" is fully closed
|
|
61
|
+
* only for cheap/derived-bridge crons; an expensive main-session poll still
|
|
62
|
+
* warms the clock because the model genuinely ran. This predicate governs only
|
|
63
|
+
* the inject-time stamp, not the session-event stamp — closing the main-
|
|
64
|
+
* session-poll case would need a separate weaker clock, out of scope here.
|
|
65
|
+
*/
|
|
66
|
+
export function isCronInjectFire(meta: Record<string, string> | undefined): boolean {
|
|
67
|
+
return meta?.source === "cron" || meta?.session === "cron";
|
|
68
|
+
}
|
|
69
|
+
|
|
38
70
|
/**
|
|
39
71
|
* Resolve the IPC routing target for an inject_inbound. When the fire
|
|
40
72
|
* carries `meta.session='cron'` it goes to the derived cron bridge; every
|