switchroom 0.17.6 → 0.18.3

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 (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -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
+ }