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,209 @@
1
+ // Typed intermediate representation (IR) for the Telegram HTML render engine.
2
+ //
3
+ // This is the parser <-> renderer contract. `parse()` (parse.ts) folds an
4
+ // mdast tree into this shape; a later increment's renderer walks it and emits
5
+ // Telegram Bot API HTML. Increment 1 lands ONLY the parser + this IR — there
6
+ // is no renderer yet.
7
+ //
8
+ // Every node carries `{ start, end }` UTF-16 source offsets copied verbatim
9
+ // from mdast `position.start.offset` / `position.end.offset`. They are UTF-16
10
+ // code-unit indices into the original markdown string, so
11
+ // `source.slice(node.start, node.end)` round-trips to the node's source text.
12
+ //
13
+ // Telegram HTML tag mapping (for the next increment — NOT implemented here):
14
+ //
15
+ // Inline
16
+ // plain -> (raw text, HTML-escaped)
17
+ // bold -> <b>…</b> (markdown `**…**`)
18
+ // italic -> <i>…</i> (markdown `*…*`)
19
+ // underline -> <u>…</u> (markdown `__…__`, Bot API 10.1)
20
+ // strike -> <s>…</s> (markdown `~~…~~`)
21
+ // spoiler -> <tg-spoiler>…</tg-spoiler> (markdown `||…||`)
22
+ // highlight -> <mark>…</mark> (markdown `==…==`, Bot API 10.1)
23
+ // code -> <code>…</code>
24
+ // link -> <a href="…">…</a>
25
+ //
26
+ // Block
27
+ // paragraph -> children joined; blocks separated by "\n\n"
28
+ // heading -> <b>…</b> (Telegram HTML has no <h1>…<h6>; bold + newlines)
29
+ // blockquote -> <blockquote>…</blockquote>
30
+ // (expandable === true -> <blockquote expandable>)
31
+ // code-block -> <pre><code class="language-…">…</code></pre>
32
+ // list -> rendered line-per-item with "•"/"1." bullets
33
+ // (Telegram HTML has no <ul>/<ol>)
34
+ // thematic-break -> a horizontal-rule text line (e.g. "───")
35
+ // table -> monospaced <pre> table (Telegram HTML has no <table>)
36
+
37
+ export interface Pos {
38
+ /** UTF-16 code-unit offset of the node's first char (mdast position.start.offset). */
39
+ start: number;
40
+ /** UTF-16 code-unit offset just past the node's last char (mdast position.end.offset). */
41
+ end: number;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Inline nodes
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export interface PlainNode extends Pos {
49
+ type: "plain";
50
+ text: string;
51
+ }
52
+
53
+ export interface BoldNode extends Pos {
54
+ type: "bold";
55
+ children: Inline[];
56
+ }
57
+
58
+ export interface ItalicNode extends Pos {
59
+ type: "italic";
60
+ children: Inline[];
61
+ }
62
+
63
+ /** Telegram underline (<u>…</u>). In Bot API 10.1 rich markdown the `__…__`
64
+ * double-underscore run is UNDERLINE — distinct from `**…**` bold, even though
65
+ * GFM/micromark folds both into a single `strong` mdast node. `parse.ts`
66
+ * disambiguates the two by looking at the run's source delimiter. */
67
+ export interface UnderlineNode extends Pos {
68
+ type: "underline";
69
+ children: Inline[];
70
+ }
71
+
72
+ export interface StrikeNode extends Pos {
73
+ type: "strike";
74
+ children: Inline[];
75
+ }
76
+
77
+ /** Telegram spoiler (<tg-spoiler>…</tg-spoiler>), markdown `||…||`. GFM has no
78
+ * spoiler syntax, so `parse.ts` recognises the `||…||` delimiter in a
79
+ * post-parse pass over `plain` text. */
80
+ export interface SpoilerNode extends Pos {
81
+ type: "spoiler";
82
+ children: Inline[];
83
+ }
84
+
85
+ /** Telegram highlight / marked text (<mark>…</mark>), markdown `==…==` (Bot API
86
+ * 10.1). Like spoiler, recognised by `parse.ts` in a post-parse pass over
87
+ * `plain` text (GFM has no highlight syntax). */
88
+ export interface HighlightNode extends Pos {
89
+ type: "highlight";
90
+ children: Inline[];
91
+ }
92
+
93
+ export interface CodeNode extends Pos {
94
+ type: "code";
95
+ text: string;
96
+ }
97
+
98
+ export interface LinkNode extends Pos {
99
+ type: "link";
100
+ href: string;
101
+ children: Inline[];
102
+ }
103
+
104
+ export type Inline =
105
+ | PlainNode
106
+ | BoldNode
107
+ | ItalicNode
108
+ | UnderlineNode
109
+ | StrikeNode
110
+ | SpoilerNode
111
+ | HighlightNode
112
+ | CodeNode
113
+ | LinkNode;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Block nodes
117
+ // ---------------------------------------------------------------------------
118
+
119
+ export interface ParagraphNode extends Pos {
120
+ type: "paragraph";
121
+ children: Inline[];
122
+ }
123
+
124
+ export interface HeadingNode extends Pos {
125
+ type: "heading";
126
+ /** 1..6 */
127
+ level: number;
128
+ children: Inline[];
129
+ }
130
+
131
+ export interface BlockquoteNode extends Pos {
132
+ type: "blockquote";
133
+ children: Block[];
134
+ /** Telegram <blockquote expandable>. Always false in Increment 1 — see parse.ts. */
135
+ expandable: boolean;
136
+ }
137
+
138
+ export interface CodeBlockNode extends Pos {
139
+ type: "code-block";
140
+ text: string;
141
+ language: string | null;
142
+ }
143
+
144
+ export interface ListNode extends Pos {
145
+ type: "list";
146
+ ordered: boolean;
147
+ // NOTE: the spec names this ordinal `start`, but every node already carries
148
+ // `start`/`end` UTF-16 offsets (load-bearing for round-trip slicing). To
149
+ // avoid the collision the ordered-list ordinal is `startNumber` here; its
150
+ // semantics match the spec's `list.start` exactly (mdast `list.start`).
151
+ /** First number of an ordered list (mdast `start`); null for unordered. */
152
+ startNumber: number | null;
153
+ /** Loose vs tight (mdast `list.spread`). A LOOSE list separates its items
154
+ * with a blank line in the source; a TIGHT list keeps them on adjacent
155
+ * lines. The renderer preserves this: loose lists join items with a blank
156
+ * line, tight lists (including nested sub-lists) stay on single newlines so
157
+ * no spurious blank line is injected between a tight item and its sub-list. */
158
+ spread: boolean;
159
+ items: ListItem[];
160
+ }
161
+
162
+ export interface ThematicBreakNode extends Pos {
163
+ type: "thematic-break";
164
+ }
165
+
166
+ export interface TableNode extends Pos {
167
+ type: "table";
168
+ header: TableRow;
169
+ rows: TableRow[];
170
+ /** Per-column alignment, parallel to the cells. */
171
+ align: ("left" | "center" | "right" | null)[];
172
+ }
173
+
174
+ export type Block =
175
+ | ParagraphNode
176
+ | HeadingNode
177
+ | BlockquoteNode
178
+ | CodeBlockNode
179
+ | ListNode
180
+ | ThematicBreakNode
181
+ | TableNode;
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Composite / container shapes
185
+ // ---------------------------------------------------------------------------
186
+
187
+ export interface ListItem extends Pos {
188
+ children: Block[];
189
+ /** GFM task-list state: true (checked), false (unchecked), null (not a task item). */
190
+ checked: boolean | null;
191
+ /** Loose vs tight at the ITEM level (mdast `listItem.spread`): whether this
192
+ * item's own block children are separated by a blank line in the source.
193
+ * A tight item (spread=false) — e.g. a paragraph followed by a nested
194
+ * sub-list — keeps its children on single newlines, so no blank line is
195
+ * injected before the sub-list. */
196
+ spread: boolean;
197
+ }
198
+
199
+ export interface TableRow extends Pos {
200
+ cells: TableCell[];
201
+ }
202
+
203
+ export interface TableCell extends Pos {
204
+ children: Inline[];
205
+ }
206
+
207
+ export interface Document {
208
+ blocks: Block[];
209
+ }
@@ -0,0 +1,363 @@
1
+ // Markdown -> typed IR parser for the Telegram HTML render engine.
2
+ //
3
+ // Uses `mdast-util-from-markdown` (micromark under the hood) with the GFM
4
+ // syntax + mdast extensions to obtain an mdast tree, then folds that tree into
5
+ // the IR defined in `ir.ts`. This is Increment 1: parser + IR only. There is
6
+ // no renderer and no chunker yet, and nothing here is wired into the live send
7
+ // path.
8
+ //
9
+ // Design rules honored here:
10
+ // - Every emitted node copies UTF-16 source offsets straight off mdast
11
+ // `position.start.offset` / `position.end.offset`, so
12
+ // `source.slice(node.start, node.end)` round-trips to the source text.
13
+ // - Never lose text. Any mdast node type outside the supported palette
14
+ // degrades to a `plain` inline (or a paragraph wrapping one) carrying the
15
+ // raw source slice, rather than being dropped.
16
+ //
17
+ // Underline vs bold (`__…__` vs `**…**`):
18
+ // Telegram's Bot API 10.1 rich markdown reads a `__…__` double-underscore run
19
+ // as UNDERLINE and a `**…**` run as BOLD. GFM/micromark folds BOTH into one
20
+ // `strong` mdast node with no record of which delimiter was used. This module
21
+ // disambiguates by reading the run's source delimiter off its UTF-16 offsets
22
+ // (`source.slice(start, start+2)`): `__` → `underline`, anything else →
23
+ // `bold`. A single `_…_` / `*…*` run stays `italic` (emphasis) either way.
24
+ //
25
+ // Spoiler + highlight handling (`||…||`, `==…==`):
26
+ // The IR carries `spoiler` and `highlight` nodes but GFM/micromark has no
27
+ // syntax for either — both delimiters fold into ordinary `plain` text. Rather
28
+ // than teach micromark two custom inline extensions, this module recognises
29
+ // them in a POST-PARSE pass over the folded `plain` nodes (`expandPlainNode`),
30
+ // splitting a `||secret||` / `==marked==` run into the matching
31
+ // `SpoilerNode` / `HighlightNode`. Recognition is per-`plain`-node: the
32
+ // delimited content must be a single contiguous plain-text run on one line
33
+ // (formatting INSIDE a spoiler — `||**bold**||` — is left as literal
34
+ // delimiters, since `**bold**` is already its own mdast node). A delimiter
35
+ // that does not form a closed, non-empty, single-line pair stays literal
36
+ // plain text and is re-escaped on render. This faithfully mirrors Telegram's
37
+ // own reading of the delimiters.
38
+ //
39
+ // Blockquote expandable handling:
40
+ // The IR carries `expandable: boolean` for Telegram's expandable blockquote
41
+ // (Bot API 10.1). GFM has no expandable marker; the switchroom render path
42
+ // emits `**> ` on the FIRST line of an expandable quote (`render.ts` /
43
+ // `reference/telegram-formatting-guide.md`). micromark does NOT understand
44
+ // `**> ` as a blockquote — the leading `**` makes the line a paragraph with
45
+ // an unclosed strong-emphasis run — so this module pre-transforms each
46
+ // `**>` marker into a plain ` >` marker of IDENTICAL length (`**` → two
47
+ // spaces) before handing the text to mdast. Length preservation keeps every
48
+ // UTF-16 source offset (and therefore the never-lose-text round-trip
49
+ // invariant against the ORIGINAL source) exactly intact — only the two
50
+ // marker characters differ, and they are never part of quoted content. The
51
+ // set of line-start offsets that carried the marker is threaded into
52
+ // `foldBlock` so the matching blockquote nodes get `expandable: true`.
53
+
54
+ import { fromMarkdown } from "mdast-util-from-markdown";
55
+ import { gfm } from "micromark-extension-gfm";
56
+ import { gfmFromMarkdown } from "mdast-util-gfm";
57
+ import type {
58
+ Node as MdastNode,
59
+ Parent as MdastParent,
60
+ RootContent,
61
+ PhrasingContent,
62
+ AlignType,
63
+ } from "mdast";
64
+
65
+ import type {
66
+ Block,
67
+ Document,
68
+ Inline,
69
+ ListItem,
70
+ PlainNode,
71
+ Pos,
72
+ TableCell,
73
+ TableRow,
74
+ } from "./ir.js";
75
+
76
+ /** Copy UTF-16 offsets off an mdast node. Falls back to 0-length when a
77
+ * synthesized node lacks a position (from-markdown always sets one, but the
78
+ * mdast types make `position` optional). */
79
+ function pos(node: MdastNode): Pos {
80
+ const p = node.position;
81
+ return {
82
+ start: p?.start?.offset ?? 0,
83
+ end: p?.end?.offset ?? 0,
84
+ };
85
+ }
86
+
87
+ /** Raw source text a node spans — used for the never-lose-text fallback. */
88
+ function slice(source: string, node: MdastNode): string {
89
+ const { start, end } = pos(node);
90
+ return source.slice(start, end);
91
+ }
92
+
93
+ /** The Bot API 10.1 expandable-blockquote marker: `**>` at the very start of
94
+ * a line (column 0). This is exactly what the render path emits
95
+ * (`render.ts` writes `**> ` on the first line of an expandable quote; see
96
+ * `reference/telegram-formatting-guide.md`). Matching only at column 0 keeps
97
+ * the length-preserving rewrite (`**` → two spaces) inside CommonMark's
98
+ * 3-space blockquote-indent budget — allowing leading indent here would push
99
+ * the rewritten ` >` past 3 spaces and turn it into an indented code block. */
100
+ const EXPANDABLE_MARKER_RE = /^\*\*>/;
101
+
102
+ /** Pre-transform expandable-blockquote markers so mdast can parse them as
103
+ * ordinary blockquotes, WITHOUT shifting any source offset. Each line that
104
+ * opens with `**>` has its two `*` characters replaced by two spaces
105
+ * (`**>` → ` >`), which micromark reads as a normal (optionally
106
+ * 1–3-space-indented) blockquote line. Returns the rewritten text plus the
107
+ * set of line-start offsets that carried the marker — `foldBlock` uses that
108
+ * set to flip `expandable: true` on the produced blockquote nodes. */
109
+ function markExpandableQuotes(markdown: string): {
110
+ text: string;
111
+ expandableLineStarts: Set<number>;
112
+ } {
113
+ const expandableLineStarts = new Set<number>();
114
+ let out = "";
115
+ let offset = 0;
116
+ // Split keeping the trailing newline on each line so offsets are exact.
117
+ for (const line of markdown.split(/(?<=\n)/)) {
118
+ if (EXPANDABLE_MARKER_RE.test(line)) {
119
+ expandableLineStarts.add(offset);
120
+ // Replace the leading two `*` chars with two spaces; the `>` and
121
+ // everything after it are untouched (`**> x` -> ` > x`), a valid
122
+ // 2-space-indented blockquote that mdast parses normally.
123
+ out += " " + line.slice(2);
124
+ } else {
125
+ out += line;
126
+ }
127
+ offset += line.length;
128
+ }
129
+ return { text: out, expandableLineStarts };
130
+ }
131
+
132
+ /** Offset of the start of the line containing `offset` in `source`. */
133
+ function lineStart(source: string, offset: number): number {
134
+ let i = offset;
135
+ while (i > 0 && source[i - 1] !== "\n") i--;
136
+ return i;
137
+ }
138
+
139
+ /** mdast `AlignType` (null | 'left' | 'right' | 'center') passes through
140
+ * unchanged; the IR uses the same union. */
141
+ function foldAlign(a: AlignType | undefined): "left" | "center" | "right" | null {
142
+ return a ?? null;
143
+ }
144
+
145
+ function foldInline(node: PhrasingContent, source: string): Inline {
146
+ switch (node.type) {
147
+ case "text":
148
+ return { type: "plain", text: node.value, ...pos(node) };
149
+ case "strong": {
150
+ // GFM folds both `**…**` (bold) and `__…__` (underline) into one `strong`
151
+ // node. Recover the intended construct from the source delimiter.
152
+ const p = pos(node);
153
+ const underline = source.slice(p.start, p.start + 2) === "__";
154
+ return {
155
+ type: underline ? "underline" : "bold",
156
+ children: foldInlineChildren(node, source),
157
+ ...p,
158
+ };
159
+ }
160
+ case "emphasis":
161
+ return { type: "italic", children: foldInlineChildren(node, source), ...pos(node) };
162
+ case "delete":
163
+ return { type: "strike", children: foldInlineChildren(node, source), ...pos(node) };
164
+ case "inlineCode":
165
+ return { type: "code", text: node.value, ...pos(node) };
166
+ case "link":
167
+ return {
168
+ type: "link",
169
+ href: node.url,
170
+ children: foldInlineChildren(node, source),
171
+ ...pos(node),
172
+ };
173
+ // Not in the palette (break, image, html, footnoteReference, …): keep the
174
+ // raw source text so no content is lost.
175
+ default:
176
+ return { type: "plain", text: slice(source, node), ...pos(node) };
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Post-parse inline-marker recognition (spoiler `||…||`, highlight `==…==`)
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /** The post-parse inline delimiters GFM/micromark doesn't natively model. Each
185
+ * matches a CLOSED, NON-EMPTY, single-line delimited run inside `plain` text.
186
+ * `delim` is the delimiter length (both are 2 chars). */
187
+ const INLINE_MARKER_SPECS: ReadonlyArray<{
188
+ type: "spoiler" | "highlight";
189
+ re: RegExp;
190
+ delim: number;
191
+ }> = [
192
+ { type: "spoiler", re: /\|\|([^|\n]+)\|\|/, delim: 2 },
193
+ { type: "highlight", re: /==([^=\n]+)==/, delim: 2 },
194
+ ];
195
+
196
+ function mkPlain(text: string, start: number, end: number): PlainNode {
197
+ return { type: "plain", text, start, end };
198
+ }
199
+
200
+ /** Split a single `plain` node's text into `plain` / `spoiler` / `highlight`
201
+ * nodes by recognising `||…||` and `==…==` runs. Offsets are exact when the
202
+ * node's source slice equals its decoded text (the common no-escape case);
203
+ * otherwise every produced piece inherits the original node's span (still a
204
+ * valid in-bounds offset, just not byte-slice-exact). Recurses so a marker
205
+ * nested in plain text after another marker is also recognised. */
206
+ function expandPlainNode(node: PlainNode, source: string): Inline[] {
207
+ const text = node.text;
208
+ if (text.length === 0) return [node];
209
+ const exact = source.slice(node.start, node.end) === text;
210
+ const abs = (k: number): number => (exact ? node.start + k : node.start);
211
+ const absEnd = (k: number): number => (exact ? node.start + k : node.end);
212
+
213
+ // Earliest marker match across all delimiter kinds.
214
+ let best:
215
+ | { idx: number; full: number; inner: string; type: "spoiler" | "highlight"; delim: number }
216
+ | null = null;
217
+ for (const spec of INLINE_MARKER_SPECS) {
218
+ const m = spec.re.exec(text);
219
+ if (m != null && m[1].length > 0 && (best === null || m.index < best.idx)) {
220
+ best = { idx: m.index, full: m[0].length, inner: m[1], type: spec.type, delim: spec.delim };
221
+ }
222
+ }
223
+ if (best === null) return [node];
224
+
225
+ const out: Inline[] = [];
226
+ if (best.idx > 0) out.push(mkPlain(text.slice(0, best.idx), abs(0), abs(best.idx)));
227
+
228
+ const innerStart = abs(best.idx + best.delim);
229
+ const innerEnd = absEnd(best.idx + best.delim + best.inner.length);
230
+ const innerPlain = mkPlain(best.inner, innerStart, innerEnd);
231
+ out.push({
232
+ type: best.type,
233
+ children: expandPlainNode(innerPlain, source),
234
+ start: abs(best.idx),
235
+ end: absEnd(best.idx + best.full),
236
+ });
237
+
238
+ const rest = text.slice(best.idx + best.full);
239
+ if (rest.length > 0) {
240
+ out.push(...expandPlainNode(mkPlain(rest, abs(best.idx + best.full), node.end), source));
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /** Fold a run of mdast phrasing children into IR inlines, then run the
246
+ * post-parse spoiler/highlight recognition over the produced `plain` nodes. */
247
+ function buildInlines(children: ReadonlyArray<MdastNode>, source: string): Inline[] {
248
+ return children
249
+ .map((c) => foldInline(c as PhrasingContent, source))
250
+ .flatMap((n) => (n.type === "plain" ? expandPlainNode(n, source) : [n]));
251
+ }
252
+
253
+ function foldInlineChildren(node: MdastParent, source: string): Inline[] {
254
+ return buildInlines(node.children, source);
255
+ }
256
+
257
+ function foldTableRow(
258
+ row: Extract<RootContent, { type: "tableRow" }>,
259
+ source: string,
260
+ ): TableRow {
261
+ const cells: TableCell[] = row.children.map((cell) => ({
262
+ children: buildInlines(cell.children, source),
263
+ ...pos(cell),
264
+ }));
265
+ return { cells, ...pos(row) };
266
+ }
267
+
268
+ function foldBlock(
269
+ node: RootContent,
270
+ source: string,
271
+ expandableLineStarts: Set<number>,
272
+ ): Block {
273
+ switch (node.type) {
274
+ case "paragraph":
275
+ return { type: "paragraph", children: foldInlineChildren(node, source), ...pos(node) };
276
+ case "heading":
277
+ return {
278
+ type: "heading",
279
+ level: node.depth,
280
+ children: foldInlineChildren(node, source),
281
+ ...pos(node),
282
+ };
283
+ case "blockquote": {
284
+ const p = pos(node);
285
+ // A blockquote is expandable when its FIRST line carried the `**>`
286
+ // marker in the original source (recorded by markExpandableQuotes).
287
+ const expandable = expandableLineStarts.has(lineStart(source, p.start));
288
+ return {
289
+ type: "blockquote",
290
+ children: node.children.map((c) => foldBlock(c, source, expandableLineStarts)),
291
+ expandable,
292
+ ...p,
293
+ };
294
+ }
295
+ case "code":
296
+ return {
297
+ type: "code-block",
298
+ text: node.value,
299
+ language: node.lang ?? null,
300
+ ...pos(node),
301
+ };
302
+ case "list": {
303
+ const items: ListItem[] = node.children.map((li) => ({
304
+ children: li.children.map((c) => foldBlock(c, source, expandableLineStarts)),
305
+ checked: li.checked ?? null,
306
+ // mdast `listItem.spread`: whether this item's block children are
307
+ // separated by a blank line. Tight (false) keeps a paragraph and its
308
+ // nested sub-list adjacent so no blank line is injected on render.
309
+ spread: li.spread ?? false,
310
+ ...pos(li),
311
+ }));
312
+ return {
313
+ type: "list",
314
+ ordered: node.ordered ?? false,
315
+ startNumber: node.ordered ? node.start ?? 1 : null,
316
+ // mdast `list.spread`: loose (true) vs tight (false) at the list level.
317
+ spread: node.spread ?? false,
318
+ items,
319
+ ...pos(node),
320
+ };
321
+ }
322
+ case "thematicBreak":
323
+ return { type: "thematic-break", ...pos(node) };
324
+ case "table": {
325
+ const rows = node.children;
326
+ const [headerRow, ...bodyRows] = rows;
327
+ return {
328
+ type: "table",
329
+ header: foldTableRow(headerRow, source),
330
+ rows: bodyRows.map((r) => foldTableRow(r, source)),
331
+ align: (node.align ?? []).map(foldAlign),
332
+ ...pos(node),
333
+ };
334
+ }
335
+ // Not in the palette (html, definition, footnoteDefinition, …): degrade to
336
+ // a paragraph carrying the raw source slice so no content is dropped.
337
+ default:
338
+ return {
339
+ type: "paragraph",
340
+ children: [{ type: "plain", text: slice(source, node), ...pos(node) }],
341
+ ...pos(node),
342
+ };
343
+ }
344
+ }
345
+
346
+ /** Parse a markdown string into the typed IR Document. */
347
+ export function parse(markdown: string): Document {
348
+ // Rewrite expandable-blockquote markers (`**>` → ` >`) so micromark parses
349
+ // them as ordinary blockquotes. The rewrite is length-preserving, so mdast's
350
+ // offsets are valid against BOTH the rewritten and the original text — we
351
+ // parse the rewritten text but fold against the ORIGINAL `markdown`, keeping
352
+ // every source slice byte-identical to the caller's input.
353
+ const { text: rewritten, expandableLineStarts } = markExpandableQuotes(markdown);
354
+ const tree = fromMarkdown(rewritten, {
355
+ extensions: [gfm()],
356
+ mdastExtensions: [gfmFromMarkdown()],
357
+ });
358
+ return {
359
+ blocks: tree.children.map((child) =>
360
+ foldBlock(child, markdown, expandableLineStarts),
361
+ ),
362
+ };
363
+ }