switchroom 0.17.10 → 0.18.6

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 (149) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +29 -2
  3. package/dist/auth-broker/index.js +6163 -152
  4. package/dist/cli/notion-write-pretool.mjs +31 -3
  5. package/dist/cli/switchroom.js +695 -526
  6. package/dist/host-control/main.js +6184 -173
  7. package/dist/vault/approvals/kernel-server.js +5893 -165
  8. package/dist/vault/broker/server.js +6666 -921
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +2 -2
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/switchroom-release/SKILL.md +78 -0
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -25
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +740 -106
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  25. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  26. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  27. package/telegram-plugin/operator-events.ts +21 -0
  28. package/telegram-plugin/package.json +6 -0
  29. package/telegram-plugin/pending-work-progress.ts +42 -7
  30. package/telegram-plugin/quota-bar-format.ts +360 -0
  31. package/telegram-plugin/quota-watch.ts +4 -6
  32. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  33. package/telegram-plugin/registry/turns-schema.ts +78 -0
  34. package/telegram-plugin/render/ir.ts +209 -0
  35. package/telegram-plugin/render/parse.ts +363 -0
  36. package/telegram-plugin/render/render.ts +440 -0
  37. package/telegram-plugin/render/rich-render.ts +72 -0
  38. package/telegram-plugin/stream-controller.ts +14 -3
  39. package/telegram-plugin/subagent-watcher.ts +27 -9
  40. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  41. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  42. package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
  43. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  44. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  45. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  46. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  47. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  48. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  49. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  50. package/telegram-plugin/tests/model-command.test.ts +193 -16
  51. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  52. package/telegram-plugin/tests/operator-events.test.ts +16 -0
  53. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  54. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  55. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  56. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  57. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  58. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  59. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  60. package/telegram-plugin/tests/render/render.test.ts +436 -0
  61. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  62. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  63. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  64. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  65. package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
  66. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  67. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  68. package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
  69. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  70. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  71. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  72. package/telegram-plugin/worker-activity-feed.ts +220 -15
  73. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  74. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  75. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  76. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  78. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  79. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
  80. package/profiles/default/CLAUDE.md +0 -116
  81. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  82. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  126. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  127. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -0,0 +1,440 @@
1
+ // IR -> Telegram rich-markdown renderer for the Telegram HTML render engine
2
+ // (name kept for continuity with render/ir.ts + render/parse.ts; the actual
3
+ // wire format is GFM markdown, NOT HTML — see the note below).
4
+ //
5
+ // Increment 2 of the render pipeline: takes the typed IR produced by
6
+ // `parse.ts` (per `ir.ts`) and emits a string suitable for the `markdown`
7
+ // field of `InputRichMessageMarkdown` (`telegram-plugin/rich-send.ts`).
8
+ //
9
+ // IMPORTANT — why this emits markdown, not HTML:
10
+ // `ir.ts`'s block-type doc comment (written during Increment 1) sketches an
11
+ // HTML tag mapping "for the next increment." That comment predates the Bot
12
+ // API 10.1 migration (#2669, `telegram-plugin/rich-send.ts`) becoming the
13
+ // live send path: `sendRichMessage` / `editMessageText` are called with
14
+ // `{ markdown: string }` — raw GFM markdown — not with `{ text, parse_mode:
15
+ // "HTML" }`. There is no HTML anywhere on the current outbound path (see
16
+ // `reference/telegram-formatting-guide.md`). This renderer therefore targets
17
+ // the ACTUAL contract: GFM markdown with the Bot API 10.1 extensions
18
+ // documented in the formatting guide (expandable blockquote via `**> `,
19
+ // spoiler via `||…||`, GFM pipe tables, etc). This module is NOT wired into
20
+ // the live send path yet — that is a later increment, per the RFC's phased
21
+ // rollout (rich rendering stays gated off by default until then).
22
+ //
23
+ // Round-trip note: `parse.ts` folds inline text (`PlainNode.text`,
24
+ // `CodeNode.text`, `code-block` `text`, link `href`) into DECODED strings —
25
+ // entity references and escapes are already resolved by mdast. Rendering
26
+ // back to markdown therefore re-escapes GFM-special characters in prose
27
+ // context with `escapeMarkdown` (same helper `format.ts` uses for
28
+ // dynamic-content interpolation) and re-defuses embedded backticks in code
29
+ // spans with `codeSpanSafe`, so the emitted markdown parses back to
30
+ // equivalent entities rather than accidentally re-triggering formatting or
31
+ // breaking out of a code span.
32
+
33
+ import { escapeMarkdown, codeSpanSafe, RICH_MESSAGE_MAX_CHARS } from "../format.js";
34
+ import type {
35
+ Block,
36
+ BlockquoteNode,
37
+ CodeBlockNode,
38
+ Document,
39
+ Inline,
40
+ ListItem,
41
+ ListNode,
42
+ TableNode,
43
+ TableRow,
44
+ } from "./ir.js";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Inline rendering
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function renderInline(node: Inline): string {
51
+ switch (node.type) {
52
+ case "plain":
53
+ return escapeMarkdown(node.text);
54
+ case "bold":
55
+ return `**${renderInlineChildren(node.children)}**`;
56
+ case "italic":
57
+ return `*${renderInlineChildren(node.children)}*`;
58
+ case "underline":
59
+ return `__${renderInlineChildren(node.children)}__`;
60
+ case "strike":
61
+ return `~~${renderInlineChildren(node.children)}~~`;
62
+ case "spoiler":
63
+ return `||${renderInlineChildren(node.children)}||`;
64
+ case "highlight":
65
+ return `==${renderInlineChildren(node.children)}==`;
66
+ case "code":
67
+ return `\`${codeSpanSafe(node.text)}\``;
68
+ case "link":
69
+ return `[${renderInlineChildren(node.children)}](${node.href})`;
70
+ default: {
71
+ // Exhaustiveness guard — the IR union is closed; a new variant must be
72
+ // handled above rather than silently dropped.
73
+ const _exhaustive: never = node;
74
+ return escapeMarkdown((_exhaustive as Inline & { text?: string }).text ?? "");
75
+ }
76
+ }
77
+ }
78
+
79
+ function renderInlineChildren(children: Inline[]): string {
80
+ return children.map(renderInline).join("");
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Block rendering
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /** Prefix every line of `text` with `prefix` (used for blockquote nesting). */
88
+ function prefixLines(text: string, prefix: string): string {
89
+ return text
90
+ .split("\n")
91
+ .map((line) => (line.length > 0 ? `${prefix}${line}` : prefix.trimEnd()))
92
+ .join("\n");
93
+ }
94
+
95
+ function renderBlockquote(node: BlockquoteNode): string {
96
+ const inner = renderBlocks(node.children);
97
+ // Bot API 10.1 expandable blockquote: `**> ` on the first quoted line.
98
+ // Plain blockquote: `> ` on every line.
99
+ if (node.expandable) {
100
+ const lines = inner.split("\n");
101
+ return lines
102
+ .map((line, i) => {
103
+ const marker = i === 0 ? "**> " : "> ";
104
+ return line.length > 0 ? `${marker}${line}` : marker.trimEnd();
105
+ })
106
+ .join("\n");
107
+ }
108
+ return prefixLines(inner, "> ");
109
+ }
110
+
111
+ function renderCodeBlock(node: CodeBlockNode): string {
112
+ const lang = node.language ?? "";
113
+ // Fenced code content is verbatim per the formatting guide; the only
114
+ // hazard is an embedded ``` closing the fence early. Widen the fence to a
115
+ // run of backticks one longer than the longest run already present in the
116
+ // content, mirroring the guide's `preBlock` defusal strategy.
117
+ const longestRun = Math.max(
118
+ 0,
119
+ ...(node.text.match(/`+/g) ?? []).map((run) => run.length),
120
+ );
121
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
122
+ return `${fence}${lang}\n${node.text}\n${fence}`;
123
+ }
124
+
125
+ function renderListItem(item: ListItem, ordered: boolean, index: number): string {
126
+ const checkbox =
127
+ item.checked === null ? "" : item.checked ? "[x] " : "[ ] ";
128
+ const marker = ordered ? `${index}. ` : "- ";
129
+ // Tight item (spread=false): join the item's block children with a single
130
+ // newline so a paragraph and its nested sub-list stay adjacent — no spurious
131
+ // blank line injected before the sub-list. Loose item (spread=true): keep the
132
+ // blank-line separation the source author intended.
133
+ const body = renderBlocksJoined(item.children, item.spread ? "\n\n" : "\n");
134
+ const lines = body.split("\n");
135
+ const first = `${marker}${checkbox}${lines[0] ?? ""}`;
136
+ const contIndent = " ".repeat(marker.length);
137
+ const rest = lines
138
+ .slice(1)
139
+ .map((line) => (line.length > 0 ? `${contIndent}${line}` : line));
140
+ return [first, ...rest].join("\n");
141
+ }
142
+
143
+ function renderList(node: ListNode): string {
144
+ const start = node.startNumber ?? 1;
145
+ // Loose list (spread=true): a blank line between top-level items, as the
146
+ // source author wrote them. Tight list (spread=false, the common case and
147
+ // all nested sub-lists): siblings on adjacent lines, single newline.
148
+ const sep = node.spread ? "\n\n" : "\n";
149
+ return node.items
150
+ .map((item, i) => renderListItem(item, node.ordered, start + i))
151
+ .join(sep);
152
+ }
153
+
154
+ /** Render a single cell's inline content for a table (no line breaks — GFM
155
+ * table cells can't contain them; pipes are escaped defensively). */
156
+ function renderTableCell(cells: TableRow["cells"][number]): string {
157
+ return renderInlineChildren(cells.children).replace(/\n+/g, " ");
158
+ }
159
+
160
+ function alignSeparator(align: TableNode["align"][number]): string {
161
+ switch (align) {
162
+ case "left":
163
+ return ":---";
164
+ case "right":
165
+ return "---:";
166
+ case "center":
167
+ return ":---:";
168
+ default:
169
+ return "---";
170
+ }
171
+ }
172
+
173
+ function renderTable(node: TableNode): string {
174
+ const headerCells = node.header.cells.map(renderTableCell);
175
+ const colCount = headerCells.length;
176
+ const align = node.align.length > 0 ? node.align : new Array(colCount).fill(null);
177
+ const headerLine = `| ${headerCells.join(" | ")} |`;
178
+ const sepLine = `| ${align
179
+ .slice(0, colCount)
180
+ .map(alignSeparator)
181
+ .join(" | ")} |`;
182
+ const bodyLines = node.rows.map(
183
+ (row) => `| ${row.cells.map(renderTableCell).join(" | ")} |`,
184
+ );
185
+ return [headerLine, sepLine, ...bodyLines].join("\n");
186
+ }
187
+
188
+ function renderBlock(node: Block): string {
189
+ switch (node.type) {
190
+ case "paragraph":
191
+ return renderInlineChildren(node.children);
192
+ case "heading":
193
+ return `${"#".repeat(node.level)} ${renderInlineChildren(node.children)}`;
194
+ case "blockquote":
195
+ return renderBlockquote(node);
196
+ case "code-block":
197
+ return renderCodeBlock(node);
198
+ case "list":
199
+ return renderList(node);
200
+ case "thematic-break":
201
+ return "---";
202
+ case "table":
203
+ return renderTable(node);
204
+ default: {
205
+ const _exhaustive: never = node;
206
+ return escapeMarkdown((_exhaustive as Block & { text?: string }).text ?? "");
207
+ }
208
+ }
209
+ }
210
+
211
+ /** Join a run of blocks with an explicit separator. Blockquote children and
212
+ * the top-level document use a blank line (`\n\n`); tight list items pass a
213
+ * single newline so a nested sub-list stays adjacent to its item's paragraph
214
+ * (see `renderListItem`). */
215
+ function renderBlocksJoined(blocks: Block[], sep: string): string {
216
+ return blocks.map(renderBlock).join(sep);
217
+ }
218
+
219
+ function renderBlocks(blocks: Block[]): string {
220
+ return renderBlocksJoined(blocks, "\n\n");
221
+ }
222
+
223
+ /** Render a full IR `Document` to Bot API 10.1 rich GFM markdown. */
224
+ export function render(doc: Document): string {
225
+ return renderBlocks(doc.blocks);
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Construct allowlist (gap #2: escape/degrade anything unsupported)
230
+ // ---------------------------------------------------------------------------
231
+
232
+ // The IR node types this renderer emits NATIVE Telegram markup for. The IR
233
+ // union is closed and TypeScript's exhaustiveness `default` branches in
234
+ // `renderInline` / `renderBlock` already escape any unknown variant to literal
235
+ // text rather than emitting a broken tag — so the allowlist is enforced at
236
+ // COMPILE time. These runtime constants make the allowlist inspectable (and
237
+ // unit-testable: a test asserts every IR `type` appears here, so adding an IR
238
+ // node without teaching the renderer to render it fails the build), which is
239
+ // the concrete "construct-allowlist validator" that pairs with the expandable-
240
+ // blockquote / spoiler / underline additions.
241
+ export const SUPPORTED_INLINE = [
242
+ "plain",
243
+ "bold",
244
+ "italic",
245
+ "underline",
246
+ "strike",
247
+ "spoiler",
248
+ "highlight",
249
+ "code",
250
+ "link",
251
+ ] as const;
252
+
253
+ export const SUPPORTED_BLOCK = [
254
+ "paragraph",
255
+ "heading",
256
+ "blockquote",
257
+ "code-block",
258
+ "list",
259
+ "thematic-break",
260
+ "table",
261
+ ] as const;
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Oversized / malformed-content safe fallback
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /** Which construct degraded. `document` = the whole message fell back to
268
+ * plain text. */
269
+ export type DegradationConstruct = "table" | "code-block" | "blockquote" | "document";
270
+
271
+ /** Why a construct could not be emitted as a proper rich construct.
272
+ * - `table-malformed` : the table's structure is not renderable as a rich
273
+ * GFM table (e.g. no columns, ragged rows); emitted
274
+ * as a preformatted code fence instead.
275
+ * - `oversize` : a single atomic block alone exceeds the wire cap;
276
+ * emitted as plain source text.
277
+ * - `document-oversize` : the whole document still exceeds the cap after
278
+ * per-block substitution; sent as plain text with no
279
+ * rich wrapper. */
280
+ export type DegradationReason = "table-malformed" | "oversize" | "document-oversize";
281
+
282
+ /** A tracked degradation — WHY a construct fell back from its native rich form
283
+ * to a plain/code rendering. Returned on `RenderResult` so the caller can log
284
+ * / surface it instead of the fallback happening silently. */
285
+ export interface Degradation {
286
+ construct: DegradationConstruct;
287
+ reason: DegradationReason;
288
+ /** Human-readable detail for logs / telemetry. */
289
+ detail: string;
290
+ }
291
+
292
+ export interface RenderResult {
293
+ /** The rendered text to send. */
294
+ text: string;
295
+ /** "markdown": send as rich `{ markdown }`. "plain": send as a literal
296
+ * string with no rich wrapper (structure stripped, content preserved). */
297
+ mode: "markdown" | "plain";
298
+ /** Constructs that could not be emitted in their native rich form, each with
299
+ * a tracked reason. Empty when everything rendered natively. Never silently
300
+ * degrade — a caller can log/telemeter these. */
301
+ degradations: Degradation[];
302
+ }
303
+
304
+ /**
305
+ * Classify a table that cannot render as a proper rich GFM table. Returns a
306
+ * human-readable reason string, or null when the table is well-formed.
307
+ *
308
+ * mdast normally normalises tables, but a table folded from degraded/adversarial
309
+ * source (or a future IR producer) can still be structurally unrenderable — no
310
+ * header columns, or body rows whose cell count doesn't match the header. Such a
311
+ * table would emit pipes that Telegram renders as broken/literal text, so we
312
+ * catch it here and fall back to a preformatted code fence (content preserved,
313
+ * degradation tracked) rather than shipping a broken row.
314
+ */
315
+ function tableDegradationReason(node: TableNode): string | null {
316
+ const cols = node.header.cells.length;
317
+ if (cols === 0) return "table has no header columns";
318
+ for (let i = 0; i < node.rows.length; i++) {
319
+ const n = node.rows[i].cells.length;
320
+ if (n !== cols) {
321
+ return `row ${i + 1} has ${n} cell(s), expected ${cols} to match the header`;
322
+ }
323
+ }
324
+ return null;
325
+ }
326
+
327
+ /** Wrap a block's raw source text in a widened preformatted code fence — the
328
+ * degraded rendering for a malformed table (content verbatim, no broken
329
+ * markup). Fence-widening mirrors `renderCodeBlock`. */
330
+ function degradeToCodeFence(source: string, node: Block): string {
331
+ const raw = source.slice(node.start, node.end);
332
+ const longestRun = Math.max(0, ...(raw.match(/`+/g) ?? []).map((run) => run.length));
333
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
334
+ return `${fence}\n${raw}\n${fence}`;
335
+ }
336
+
337
+ /**
338
+ * A block is "atomic" for chunking purposes when splitting it mid-construct
339
+ * would break the wire format — an unterminated fence, a half table row, or
340
+ * a blockquote whose closing marker got separated from its opener. These are
341
+ * exactly the constructs `splitMarkdownChunks` (format.ts) already knows how
342
+ * to avoid bisecting for a MULTI-block body, but a SINGLE such block that by
343
+ * itself exceeds the cap has nowhere left to cut without breaking mid-tag —
344
+ * that's the case this module must catch before send.
345
+ */
346
+ function isAtomicBlock(node: Block): boolean {
347
+ return (
348
+ node.type === "code-block" ||
349
+ node.type === "table" ||
350
+ node.type === "blockquote"
351
+ );
352
+ }
353
+
354
+ /** Render `node`'s raw source text as a plain literal (escaped so none of
355
+ * the source's own markdown-special characters accidentally re-trigger
356
+ * formatting when sent WITHOUT the rich wrapper — a plain send has no
357
+ * parser, but downstream literal display should still show the author's
358
+ * intended characters, not stray backslashes, so we use the raw slice
359
+ * as-is: a plain send does not interpret markdown at all). */
360
+ function plainSlice(source: string, node: Block): string {
361
+ return source.slice(node.start, node.end);
362
+ }
363
+
364
+ /**
365
+ * Render `doc` to Bot API 10.1 rich markdown, honoring the wire cap
366
+ * (`RICH_MESSAGE_MAX_CHARS` by default). Behaviour:
367
+ *
368
+ * 1. If the full rendered markdown fits under `maxLen`, return it as-is
369
+ * (`mode: "markdown"`).
370
+ * 2. Otherwise, any individual ATOMIC block (table / code-block /
371
+ * blockquote — constructs that cannot be safely bisected without
372
+ * producing a broken tag/fence/row) whose OWN rendered form alone
373
+ * exceeds `maxLen` is replaced by its raw plain-text source slice
374
+ * instead of being emitted as rich markdown that a later chunker would
375
+ * have to cut mid-construct.
376
+ * 3. If the document *still* exceeds `maxLen` after that per-block
377
+ * substitution (i.e. the oversized content isn't isolated to one
378
+ * swappable block, or the whole document is one giant atomic
379
+ * construct), the ENTIRE document falls back to plain text — the raw
380
+ * source, verbatim, sent with no rich wrapper at all. This never
381
+ * truncates: the caller's ordinary length-based chunker
382
+ * (`splitMarkdownChunks` and `hardSliceToCap`) is what applies size
383
+ * limits to plain text before send; this function's only job is to
384
+ * guarantee it is never handed a rich-markdown body with a mid-tag cut.
385
+ */
386
+ export function renderSafe(
387
+ doc: Document,
388
+ source: string,
389
+ maxLen: number = RICH_MESSAGE_MAX_CHARS,
390
+ ): RenderResult {
391
+ const degradations: Degradation[] = [];
392
+
393
+ // Per-block render, degrading a MALFORMED table (any size) to a preformatted
394
+ // code fence up front so a structurally-unrenderable table never ships as
395
+ // broken pipes — and the reason is recorded, not swallowed.
396
+ const rendered = doc.blocks.map((block) => {
397
+ if (block.type === "table") {
398
+ const reason = tableDegradationReason(block);
399
+ if (reason != null) {
400
+ degradations.push({ construct: "table", reason: "table-malformed", detail: reason });
401
+ return { block, text: degradeToCodeFence(source, block) };
402
+ }
403
+ }
404
+ return { block, text: renderBlock(block) };
405
+ });
406
+
407
+ const full = rendered.map((r) => r.text).join("\n\n");
408
+ if (full.length <= maxLen) {
409
+ return { text: full, mode: "markdown", degradations };
410
+ }
411
+
412
+ // Oversized: swap any atomic block whose OWN rendered form alone exceeds the
413
+ // cap for its raw plain-text source (a construct that can't be safely
414
+ // bisected), recording an `oversize` degradation for each.
415
+ const swapped = rendered.map(({ block, text }) => {
416
+ if (isAtomicBlock(block) && text.length > maxLen) {
417
+ degradations.push({
418
+ construct: block.type as DegradationConstruct,
419
+ reason: "oversize",
420
+ detail: `${block.type} of ${text.length} chars exceeds the ${maxLen}-char cap; sent as plain source text`,
421
+ });
422
+ return escapeMarkdown(plainSlice(source, block));
423
+ }
424
+ return text;
425
+ });
426
+ const patched = swapped.join("\n\n");
427
+ if (patched.length <= maxLen) {
428
+ return { text: patched, mode: "markdown", degradations };
429
+ }
430
+
431
+ // Still oversized (or a giant atomic block never fit even as plain text) —
432
+ // fall all the way back to plain text for the whole document. Never
433
+ // truncate here; that is the length-based chunker's job downstream.
434
+ degradations.push({
435
+ construct: "document",
436
+ reason: "document-oversize",
437
+ detail: `rendered document of ${patched.length} chars exceeds the ${maxLen}-char cap; sent as plain text without the rich wrapper`,
438
+ });
439
+ return { text: source, mode: "plain", degradations };
440
+ }
@@ -0,0 +1,72 @@
1
+ // Flag-gated wiring for the Bot API 10.1 rich renderer (parse.ts + render.ts)
2
+ // into the live outbound send path.
3
+ //
4
+ // The renderer (`render/render.ts`, PR #2930) and parser (`render/parse.ts`)
5
+ // have full unit coverage but were, until this module, wired into NOTHING —
6
+ // no outbound message ever flowed through them. This module is the single,
7
+ // feature-flagged bridge: the gateway's rich send path (stream-controller.ts)
8
+ // runs the assistant's raw markdown through `parse → renderSafe` before it
9
+ // reaches `sendRichMessage`, but ONLY when the flag is on.
10
+ //
11
+ // Feature flag — `SWITCHROOM_RICH_RENDER`, default OFF:
12
+ // Mirrors the `SWITCHROOM_VISIBLE_ANSWER_STREAM` convention
13
+ // (`answer-stream-flag.ts`): an env var read at runtime, default off, opted
14
+ // in PER AGENT via the `env:` block in `switchroom.yaml` (propagated into
15
+ // the container `environment:` by `src/agents/compose.ts`). When off,
16
+ // `maybeRenderOutbound` returns the input untouched, so the live send path
17
+ // is byte-for-byte unchanged — no agent's behaviour moves until an operator
18
+ // explicitly flips the flag for a specific agent. Accepts `1/true/on/yes`.
19
+ //
20
+ // Why route through `renderSafe` and not bare `render`:
21
+ // `renderSafe` guarantees the returned body is never a rich-markdown string
22
+ // with a mid-construct cut, and degrades a document it can't safely fit to
23
+ // plain-text mode (structure stripped, content preserved). The send path
24
+ // honours that `mode` — a `plain` result is sent WITHOUT the rich wrapper.
25
+
26
+ import { parse } from "./parse.js";
27
+ import { renderSafe, type RenderResult } from "./render.js";
28
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
29
+
30
+ /** Parse the `SWITCHROOM_RICH_RENDER` flag value. Default OFF; accepts the
31
+ * same truthy tokens as the other switchroom env flags. Pure so the default
32
+ * + parsing are unit-testable. */
33
+ export function parseRichRenderEnabled(raw: string | undefined): boolean {
34
+ if (raw == null) return false;
35
+ const v = raw.trim().toLowerCase();
36
+ return v === "1" || v === "true" || v === "on" || v === "yes";
37
+ }
38
+
39
+ /** Is the rich renderer enabled in this process? Reads the env flag live so a
40
+ * test can set/unset it per-case; defaults OFF. */
41
+ export function richRenderEnabled(
42
+ env: NodeJS.ProcessEnv = process.env,
43
+ ): boolean {
44
+ return parseRichRenderEnabled(env.SWITCHROOM_RICH_RENDER);
45
+ }
46
+
47
+ /** Unconditionally run `text` through `parse → renderSafe` (ignores the flag).
48
+ * Exposed for tests and callers that have already gated on the flag. */
49
+ export function renderOutbound(
50
+ text: string,
51
+ maxLen: number = RICH_MESSAGE_MAX_CHARS,
52
+ ): RenderResult {
53
+ return renderSafe(parse(text), text, maxLen);
54
+ }
55
+
56
+ /**
57
+ * Flag-gated transform for the live send path.
58
+ *
59
+ * - flag OFF (default): returns the input untouched, `mode: "markdown"` —
60
+ * identical to the pre-existing behaviour (raw transcript markdown sent
61
+ * straight to `sendRichMessage`). No behavioural change for any agent.
62
+ * - flag ON: returns `parse → renderSafe` output. `mode: "plain"` signals
63
+ * the caller to send WITHOUT the rich wrapper (oversized/unsafe content).
64
+ */
65
+ export function maybeRenderOutbound(
66
+ text: string,
67
+ env: NodeJS.ProcessEnv = process.env,
68
+ maxLen: number = RICH_MESSAGE_MAX_CHARS,
69
+ ): RenderResult {
70
+ if (!richRenderEnabled(env)) return { text, mode: "markdown", degradations: [] };
71
+ return renderOutbound(text, maxLen);
72
+ }
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { createDraftStream, type DraftStreamHandle } from './draft-stream.js'
23
23
  import { richMessage, isParseEntitiesError } from './rich-send.js'
24
+ import { maybeRenderOutbound } from './render/rich-render.js'
24
25
 
25
26
  /**
26
27
  * Minimal bot.api surface the controller needs. Real callers pass grammy's
@@ -224,12 +225,22 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
224
225
  // control previews via entity detection), so strip it for the rich path.
225
226
  const doSend = (text: string, opts: StreamSendOpts) => {
226
227
  if (literalText) return bot.api.sendMessage(chatId, text, opts)
228
+ // Flag-gated rich render (`SWITCHROOM_RICH_RENDER`, default OFF — returns
229
+ // the text untouched as markdown when off, so this is a no-op for every
230
+ // agent until an operator opts in). A `plain` result (oversized/unsafe
231
+ // content renderSafe declined to emit as rich) sends WITHOUT the wrapper.
232
+ const rendered = maybeRenderOutbound(text)
233
+ if (rendered.mode === 'plain') return bot.api.sendMessage(chatId, rendered.text, opts)
227
234
  const richOpts = { ...opts }
228
235
  delete richOpts.link_preview_options
229
- return bot.api.sendRichMessage(chatId, richMessage(text), richOpts)
236
+ return bot.api.sendRichMessage(chatId, richMessage(rendered.text), richOpts)
237
+ }
238
+ const doEdit = (id: number, text: string, opts: StreamSendOpts) => {
239
+ if (literalText) return bot.api.editMessageText(chatId, id, text, opts)
240
+ const rendered = maybeRenderOutbound(text)
241
+ if (rendered.mode === 'plain') return bot.api.editMessageText(chatId, id, rendered.text, opts)
242
+ return bot.api.editMessageText(chatId, id, richMessage(rendered.text), opts)
230
243
  }
231
- const doEdit = (id: number, text: string, opts: StreamSendOpts) =>
232
- bot.api.editMessageText(chatId, id, literalText ? text : richMessage(text), opts)
233
244
 
234
245
  return createDraftStream(
235
246
  async (text) => {
@@ -918,8 +918,8 @@ export function readSubTail(
918
918
  // (stage-on-text, resolve-on-tool/turn_end) can replay a previously
919
919
  // pending block exactly once. `latestSummary` carries the worker's
920
920
  // narrative result (entry.lastResultText), never tool labels.
921
- const fireNarrativeProgress = (): void => {
922
- if (onProgress == null || entry.state !== 'running' || entry.historical) return
921
+ const fireNarrativeProgress = (): boolean => {
922
+ if (onProgress == null || entry.state !== 'running' || entry.historical) return false
923
923
  try {
924
924
  onProgress({
925
925
  agentId: entry.agentId,
@@ -933,8 +933,10 @@ export function readSubTail(
933
933
  lastTool: entry.lastTool,
934
934
  toolCount: entry.toolCount,
935
935
  })
936
+ return true
936
937
  } catch (cbErr) {
937
938
  log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
939
+ return false
938
940
  }
939
941
  }
940
942
  // Resolve a pending sub-agent narrative against a lookahead event.
@@ -952,21 +954,27 @@ export function readSubTail(
952
954
  // compare the trailing block against the worker's last reply text
953
955
  // (`entry.lastReplyText`) and suppress a draft. Background workers
954
956
  // never set lastReplyText, so their trailing narration still SHOWs.
957
+ // Returns true iff a narrative onProgress cue actually fired this
958
+ // call — callers use this to skip a redundant/clobbering tool-label
959
+ // onProgress cue for the SAME tick (see #1042 below: without this,
960
+ // the tool-description onProgress unconditionally fires right after
961
+ // and its replace-on-write onProgress always wins, so the narration
962
+ // shown here is never actually visible on the pinned card).
955
963
  const resolvePendingSubNarrative = (
956
964
  toolName: string | null,
957
965
  toolInput: Record<string, unknown> | undefined,
958
- ): void => {
959
- if (entry.pendingNarrative == null) return
966
+ ): boolean => {
967
+ if (entry.pendingNarrative == null) return false
960
968
  const pending = entry.pendingNarrative
961
969
  entry.pendingNarrative = null
962
970
  if (toolName != null && REPLY_TOOLS.has(toolName)) {
963
971
  const replyText = typeof toolInput?.text === 'string' ? (toolInput.text as string) : ''
964
- if (isDraftOfReply(pending.text, replyText)) return // draft of the reply → SUPPRESS
972
+ if (isDraftOfReply(pending.text, replyText)) return false // draft of the reply → SUPPRESS
965
973
  } else if (toolName == null && entry.lastReplyText != null && entry.lastReplyText.length > 0) {
966
974
  // turn_end path: suppress a trailing draft of the delivered answer.
967
- if (isDraftOfReply(pending.text, entry.lastReplyText)) return
975
+ if (isDraftOfReply(pending.text, entry.lastReplyText)) return false
968
976
  }
969
- fireNarrativeProgress()
977
+ return fireNarrativeProgress()
970
978
  }
971
979
  for (const ev of events) {
972
980
  const idleSecBeforeBump = Math.round((now - entry.lastActivityAt) / 1000)
@@ -1012,7 +1020,7 @@ export function readSubTail(
1012
1020
  // this tool is the lookahead that decides it (SHOW unless it drafts
1013
1021
  // a reply tool's text). Runs before the tool's own progress cue so
1014
1022
  // a working preamble surfaces just ahead of its tool step.
1015
- resolvePendingSubNarrative(ev.toolName, ev.input)
1023
+ const narrativeJustFired = resolvePendingSubNarrative(ev.toolName, ev.input)
1016
1024
  // NIT 3: capture a foreground sub-agent's actual reply text so the
1017
1025
  // turn_end path can suppress a trailing draft of it (see
1018
1026
  // resolvePendingSubNarrative). Only REPLY_TOOLS carry the answer.
@@ -1039,7 +1047,17 @@ export function readSubTail(
1039
1047
  // stays the worker's narrative result (never polluted with tool
1040
1048
  // labels — the handback payload depends on it). Pure jsonl-tail →
1041
1049
  // render, no model call.
1042
- if (onProgress != null && entry.state === 'running' && !entry.historical) {
1050
+ //
1051
+ // Clobber guard: if a pending narrative just fired an onProgress
1052
+ // cue THIS SAME tick (above), skip this one — replace-on-write
1053
+ // rendering means whichever onProgress call fires last wins, so
1054
+ // firing both back-to-back always threw away the narration in
1055
+ // favour of the generic tool label. Narration already told the
1056
+ // user what's happening for this tick; the label is redundant
1057
+ // here. When nothing preceded this tool call (no pending
1058
+ // narrative), this still fires — that's the named foreground
1059
+ // blindspot fix, unchanged.
1060
+ if (onProgress != null && entry.state === 'running' && !entry.historical && !narrativeJustFired) {
1043
1061
  const toolLine = describeToolUse(ev.toolName, ev.input ?? {})
1044
1062
  if (toolLine != null && toolLine.length > 0) {
1045
1063
  try {