switchroom 0.21.7 → 0.21.9

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 (59) hide show
  1. package/bin/tmp-reaper.sh +234 -0
  2. package/dist/agent-scheduler/index.js +1 -1
  3. package/dist/auth-broker/index.js +2 -2
  4. package/dist/cli/notion-write-pretool.mjs +1 -1
  5. package/dist/cli/switchroom.js +3520 -2782
  6. package/dist/host-control/main.js +177 -13
  7. package/dist/vault/approvals/kernel-server.js +2 -2
  8. package/dist/vault/broker/server.js +2 -2
  9. package/package.json +6 -5
  10. package/profiles/_base/start.sh.hbs +115 -0
  11. package/profiles/_shared/local-time.md.hbs +6 -0
  12. package/profiles/default/CLAUDE.md.hbs +0 -12
  13. package/skills/switchroom-architecture/telegram.md +12 -10
  14. package/skills/switchroom-cli/SKILL.md +1 -1
  15. package/telegram-plugin/README.md +3 -1
  16. package/telegram-plugin/dist/gateway/gateway.js +1164 -547
  17. package/telegram-plugin/format.ts +12 -4
  18. package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
  19. package/telegram-plugin/gateway/approval-hold.ts +32 -1
  20. package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
  21. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
  23. package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
  24. package/telegram-plugin/gateway/gateway.ts +12 -10
  25. package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
  26. package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
  27. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
  28. package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
  29. package/telegram-plugin/package.json +1 -1
  30. package/telegram-plugin/render/code-segments.ts +38 -4
  31. package/telegram-plugin/render/dollar-math-guard.ts +16 -1
  32. package/telegram-plugin/render/ir.ts +53 -3
  33. package/telegram-plugin/render/parse.ts +73 -14
  34. package/telegram-plugin/render/render.ts +53 -15
  35. package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
  36. package/telegram-plugin/rich-send.ts +22 -7
  37. package/telegram-plugin/shared/bot-runtime.ts +3 -2
  38. package/telegram-plugin/telegraph.ts +6 -4
  39. package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
  40. package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
  41. package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
  42. package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
  43. package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
  44. package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
  45. package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
  46. package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
  47. package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
  48. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
  49. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
  50. package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
  51. package/telegram-plugin/tests/render/parse.test.ts +30 -5
  52. package/telegram-plugin/tests/render/render.test.ts +9 -4
  53. package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
  54. package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
  55. package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
  56. package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
  57. package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
  58. package/telegram-plugin/tests/telegraph.test.ts +1 -1
  59. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
@@ -21,20 +21,86 @@
21
21
 
22
22
  import type { Bot, Context } from 'grammy'
23
23
  import type { RetryCallOpts } from '../retry-api-call.js'
24
- import type { PostSkillProposalMessage, PostEvalCaseProposalMessage } from './ipc-protocol.js'
24
+ import type { PostSkillProposalMessage, PostEvalCaseProposalMessage, InboundMessage } from './ipc-protocol.js'
25
25
  import { renderSkillProposalCard, skillProposalKeyboard } from './skill-proposal-card.js'
26
26
  import { renderEvalCaseProposalCard, evalCaseProposalKeyboard } from './eval-case-proposal-card.js'
27
27
  import {
28
28
  enqueueProposal as enqueueSkillProposal,
29
29
  isSuppressed as isSkillProposalSuppressed,
30
+ REJECTION_TTL_MS,
30
31
  } from '../../src/self-improve/skill-proposals.js'
31
- import { enqueueEvalCaseProposal } from '../../src/self-improve/eval-case-proposals.js'
32
+ import {
33
+ enqueueEvalCaseProposal,
34
+ readEvalCaseProposals,
35
+ } from '../../src/self-improve/eval-case-proposals.js'
32
36
 
33
37
  /** Collaborators the gateway injects into each handler. */
34
38
  export interface ProposalWiringDeps {
35
39
  bot: Bot<Context>
36
40
  assertAllowedChat: (chatId: string) => void
37
41
  swallowingApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T | undefined>
42
+ /**
43
+ * Wake the proposing agent with a synthetic inbound (gateway.ts's
44
+ * `deliverResumeSyntheticOrBuffer`). REQUIRED, not optional: the suppressed
45
+ * branch below is a silent exit, and a call site that forgot to wire this
46
+ * would reinstate exactly the wait-forever bug it exists to prevent — so the
47
+ * type system forces every caller to supply it.
48
+ */
49
+ deliverResumeSyntheticOrBuffer: (agent: string, inbound: InboundMessage) => boolean
50
+ }
51
+
52
+ /**
53
+ * Build the synthetic inbound that tells a proposing agent its eval case was
54
+ * SUPPRESSED as a duplicate of one the operator already dismissed.
55
+ *
56
+ * WHY THIS EXISTS (cross-PR, #4662 + #4664). #4662 makes the agent's contract
57
+ * "fire `add-eval-case`, end your turn, wait for the outcome inbound". #4664
58
+ * adds a branch that posts NO card. The propose CLI is fire-and-forget — it
59
+ * prints `ok:true` for the IPC SEND, never for a card being posted
60
+ * (src/cli/self-improve-eval-case.ts) — so without this the agent would end its
61
+ * turn and wait for a tap that can never come: the exact silent block #4662
62
+ * exists to eliminate, re-entering through a new door.
63
+ *
64
+ * The tone is deliberate. Suppression is the system working as designed, not a
65
+ * failure, so the text says so plainly — an agent told only "no card" would
66
+ * reasonably retry, which is the nagging loop #4664 is removing.
67
+ *
68
+ * Co-located here rather than in #4662's `eval-case-proposal-inbound-builders.ts`
69
+ * because that module does not exist on this branch; consolidating the four
70
+ * builders once both PRs land is a tracked follow-up.
71
+ */
72
+ export function buildEvalCaseSuppressedInbound(opts: {
73
+ agent: string
74
+ chatId: string
75
+ threadId?: number
76
+ skillSlug: string
77
+ fingerprint: string
78
+ nowMs?: number
79
+ }): InboundMessage {
80
+ const ts = opts.nowMs ?? Date.now()
81
+ return {
82
+ type: 'inbound',
83
+ chatId: opts.chatId,
84
+ ...(opts.threadId != null ? { threadId: opts.threadId } : {}),
85
+ messageId: ts, // synthetic — no Telegram message id exists
86
+ user: 'self-improve',
87
+ userId: 0,
88
+ ts,
89
+ text:
90
+ `ℹ️ Your proposed eval case for \`${opts.skillSlug}\` was NOT posted as a ` +
91
+ `card: the operator already dismissed this exact case, and the dismissal ` +
92
+ `is still in effect. This is EXPECTED and is not an error or a failure on ` +
93
+ `your part — nothing was written and no card is pending, so do NOT wait ` +
94
+ `for an approval tap. Do NOT re-propose this case. Carry on with the ` +
95
+ `original task without it.`,
96
+ meta: {
97
+ source: 'eval_case_suppressed',
98
+ agent: opts.agent,
99
+ ...(opts.threadId != null ? { message_thread_id: String(opts.threadId) } : {}),
100
+ skill_slug: opts.skillSlug,
101
+ fingerprint: opts.fingerprint,
102
+ },
103
+ }
38
104
  }
39
105
 
40
106
  /**
@@ -113,6 +179,61 @@ export function handlePostSkillProposal(
113
179
  )
114
180
  }
115
181
 
182
+ /**
183
+ * True iff the operator already DISMISSED this exact eval case within the
184
+ * rejection TTL — the eval-case twin of `isSuppressed` in skill-proposals.ts.
185
+ *
186
+ * Matching is deliberately TIGHTER than the skill path's. A skill proposal is
187
+ * free prose, so that check has to fuzzy-match (jaccard over content words,
188
+ * with the slug only relaxing the threshold). An eval case carries a
189
+ * precomputed `fingerprint` — `caseFingerprint(prompt)`, a hash of the
190
+ * normalized prompt — and the CLI's own dedup against a skill's applied
191
+ * evals.json compares those fingerprints EXACTLY
192
+ * (src/cli/self-improve-eval-case.ts). So this requires exact fingerprint
193
+ * equality AND same-slug, matching the CLI's per-skill dedup scope: no fuzzy
194
+ * bar to tune, and a different PROMPT can never be swallowed.
195
+ *
196
+ * KNOWN CONSEQUENCE — identity is the PROMPT, not the whole case.
197
+ * `caseFingerprint` hashes the normalized prompt ONLY; an `EvalCase` also
198
+ * carries `expected_output` and `expectations` (src/self-improve/eval-cases.ts),
199
+ * and neither is in the hash. So re-proposing the SAME prompt with a CORRECTED
200
+ * expected_output/expectations fingerprints identically to the dismissed one and
201
+ * stays suppressed for the full TTL. This is deliberate, not an oversight: it is
202
+ * the same identity the CLI's own evals.json dedup uses, so a looser rule here
203
+ * would disagree with the applier and re-admit cases the CLI would then reject.
204
+ * To land a revised assertion for an already-dismissed prompt, reword the prompt
205
+ * (a genuinely different input) or wait out REJECTION_TTL_MS.
206
+ *
207
+ * The TTL semantics ARE mirrored (a dismissal shouldn't suppress forever), on
208
+ * the same REJECTION_TTL_MS window. The eval-case store records no
209
+ * `rejected_at`, so age is measured from `created_at`, which is always <= the
210
+ * rejection time — the window therefore expires no LATER than a true
211
+ * rejected_at basis would, erring towards re-surfacing rather than
212
+ * over-suppressing. A record with an unparseable timestamp is treated as
213
+ * expired, same as the skill path.
214
+ */
215
+ export function isEvalCaseProposalSuppressed(
216
+ stateDir: string,
217
+ candidate: { skillSlug: string; fingerprint: string },
218
+ opts: { now?: () => number; ttlMs?: number } = {},
219
+ ): boolean {
220
+ // A missing/empty fingerprint carries no identity — never suppress on it.
221
+ if (typeof candidate.fingerprint !== 'string' || candidate.fingerprint.length === 0) {
222
+ return false
223
+ }
224
+ const now = (opts.now ?? Date.now)()
225
+ const ttl = opts.ttlMs ?? REJECTION_TTL_MS
226
+ for (const p of readEvalCaseProposals(stateDir)) {
227
+ if (p.status !== 'rejected') continue
228
+ if (p.skill_slug !== candidate.skillSlug) continue
229
+ if (p.fingerprint !== candidate.fingerprint) continue
230
+ const age = now - new Date(p.created_at).getTime()
231
+ if (!Number.isFinite(age) || age > ttl) continue // expired
232
+ return true
233
+ }
234
+ return false
235
+ }
236
+
116
237
  /**
117
238
  * RFC amendment §"corrections as eval cases" — persist an eval-case proposal
118
239
  * and post its Approve/Dismiss card. On Approve the callback runs the
@@ -124,7 +245,7 @@ export function handlePostEvalCaseProposal(
124
245
  msg: PostEvalCaseProposalMessage,
125
246
  deps: ProposalWiringDeps,
126
247
  ): void {
127
- const { bot, assertAllowedChat, swallowingApiCall } = deps
248
+ const { bot, assertAllowedChat, swallowingApiCall, deliverResumeSyntheticOrBuffer } = deps
128
249
  const self = process.env.SWITCHROOM_AGENT_NAME
129
250
  if (self && msg.agentName !== self) {
130
251
  process.stderr.write(
@@ -145,6 +266,34 @@ export function handlePostEvalCaseProposal(
145
266
  process.stderr.write(`telegram gateway: post_eval_case_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
146
267
  return
147
268
  }
269
+ // Dedup against still-live dismissals — never re-surface an eval case the
270
+ // operator already tapped Dismiss on (the skill path's guard, which the
271
+ // eval-case path was missing: a dismissed case re-posted the same card on
272
+ // every subsequent correction, since the CLI only dedups against evals.json
273
+ // entries that were already APPLIED).
274
+ if (isEvalCaseProposalSuppressed(stateDir, {
275
+ skillSlug: msg.skillSlug,
276
+ fingerprint: msg.fingerprint,
277
+ })) {
278
+ // Tell the agent, or this becomes a silent exit it waits on forever (see
279
+ // buildEvalCaseSuppressedInbound). No card is posted and nothing is written
280
+ // — the operator's dismissal already decided this case.
281
+ const delivered = deliverResumeSyntheticOrBuffer(
282
+ msg.agentName,
283
+ buildEvalCaseSuppressedInbound({
284
+ agent: msg.agentName,
285
+ chatId: msg.chatId,
286
+ ...(msg.threadId != null ? { threadId: msg.threadId } : {}),
287
+ skillSlug: msg.skillSlug,
288
+ fingerprint: msg.fingerprint,
289
+ }),
290
+ )
291
+ process.stderr.write(
292
+ `telegram gateway: post_eval_case_proposal suppressed (dismissed before) ` +
293
+ `slug=${msg.skillSlug} fp=${msg.fingerprint} delivered=${delivered}\n`,
294
+ )
295
+ return
296
+ }
148
297
  const proposal = enqueueEvalCaseProposal(stateDir, {
149
298
  skill_slug: msg.skillSlug,
150
299
  skill_dir: msg.skillDir,
@@ -176,6 +176,25 @@ export const INBOUND_SOURCE_CLASSIFICATION: Record<string, { decoupledCompletion
176
176
  mental_model_proposal_failed: { decoupledCompletion: false },
177
177
  webhook: { decoupledCompletion: false },
178
178
  linear: { decoupledCompletion: false },
179
+ // Eval-case proposal outcomes (#4662) — the operator's Approve/Dismiss tap on
180
+ // an eval-case card wakes the PROPOSING agent with one of these, built in
181
+ // `eval-case-proposal-inbound-builders.ts` and injected via
182
+ // `deliverResumeSyntheticOrBuffer`. Exactly the `skill_proposal_apply` shape
183
+ // above: each lands as its OWN live inbound turn, so its reply resolves the
184
+ // live tier for its own turnId and structurally cannot supersede a different
185
+ // ended turn — it must NOT stamp. (Left unclassified, the fail-safe default
186
+ // stamps, holding the content gate chat-wide for 60 s after every eval-case
187
+ // decision and re-opening the reworded-own-answer visible dup in that window.)
188
+ eval_case_applied: { decoupledCompletion: false },
189
+ eval_case_rejected: { decoupledCompletion: false },
190
+ eval_case_apply_failed: { decoupledCompletion: false },
191
+ // Eval-case proposal SUPPRESSED (#4664): the gateway declined to post a card
192
+ // because the operator already dismissed this exact case, and tells the
193
+ // proposing agent so instead of exiting silently
194
+ // (buildEvalCaseSuppressedInbound, self-improve-proposal-wiring.ts). Delivered
195
+ // via `deliverResumeSyntheticOrBuffer` as its OWN live inbound turn, same as
196
+ // skill_proposal_apply above — it must NOT stamp.
197
+ eval_case_suppressed: { decoupledCompletion: false },
179
198
  // Buzz co-channel (Phase 1): a Nostr kind:9 group message the buzz sidecar
180
199
  // injects onto the gateway IPC queue as its OWN live inbound turn (anonymous
181
200
  // inject, `meta.source="buzz"`) — never a decoupled completion resolving a
@@ -31,7 +31,7 @@
31
31
  "@secretlint/core": "^12.2.0",
32
32
  "@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
33
33
  "@xterm/headless": "^6.0.0",
34
- "grammy": "^1.44",
34
+ "grammy": "^1.45",
35
35
  "mdast-util-from-markdown": "^2.0.2",
36
36
  "mdast-util-gfm": "^3.0.0",
37
37
  "micromark-extension-gfm": "^3.0.0",
@@ -73,6 +73,13 @@ export function splitCodeSegments(text: string): Segment[] {
73
73
  // • bare autolinked URLs — `http(s)://…` and `www.…` runs Telegram auto-links.
74
74
  // • GFM table rows — a table's structural pipes / empty cells (`|a||b|`) must
75
75
  // survive; escaping inside a real table row corrupts the table.
76
+ // • inline-math `$…$` spans — Telegram's rich GFM parser typesets a `$…$`
77
+ // pair as a real mathematical_expression node (wire-verified 2026-08-13).
78
+ // A COMPACT span (whitespace-free inner run that is not a bare currency
79
+ // amount, e.g. `$x^2+y^2$`) is intentional math and must reach the wire
80
+ // byte-identical: no guard may escape its `$`, `^`, `_`, `*`, or `+`.
81
+ // Currency-shaped inners (`$5M-$`) are NOT protected, so the dollar-math
82
+ // guard still breaks accidental `$5M … $10M` currency pairs (#3252).
76
83
  // Everything else is prose and stays fully guarded. This is the sibling of the
77
84
  // code-span skip: a PROTECTED segment (`code: true`) is emitted verbatim.
78
85
  //
@@ -93,6 +100,19 @@ function isTableCandidateLine(line: string): boolean {
93
100
  return /^\s*\|/.test(line);
94
101
  }
95
102
 
103
+ /** A compact `$…$` inline-math pair: opening `$`, a whitespace-free inner run
104
+ * with no nested `$`, closing `$`. Anchored — tested at the scanner's current
105
+ * position only. */
106
+ const COMPACT_MATH_PAIR = /^\$([^\s$]+)\$/;
107
+
108
+ /** A currency-shaped inner run: digits plus amount punctuation and magnitude
109
+ * suffix letters only (`5M-`, `0.5`, `10,000`, `5+`). Such a run between two
110
+ * `$` is a pair of ADJACENT currency amounts (`$5M-$10M`), not math — it must
111
+ * stay guardable so the dollar-math guard can break the accidental pair. A
112
+ * run containing any other character (`x^2+y^2`, `\alpha`, `a_b`) is treated
113
+ * as intentional math and protected. */
114
+ const CURRENCY_SHAPED_INNER = /^[0-9.,+\-kKmMbB]+$/;
115
+
96
116
  /** Find the [start, end) char ranges (relative to `text`) of GFM table blocks —
97
117
  * maximal runs of 2+ consecutive `|`-leading lines that contain a delimiter
98
118
  * row. Each returned range spans whole lines INCLUDING their trailing newline,
@@ -185,6 +205,19 @@ function splitProseProtected(text: string): Segment[] {
185
205
  continue;
186
206
  }
187
207
  }
208
+ // 4. Compact inline-math pair `$…$` — a supported Telegram construct
209
+ // (mathematical_expression, wire-verified 2026-08-13) that must reach
210
+ // the wire verbatim. Currency-shaped inners are NOT math (they are two
211
+ // adjacent amounts like `$5M-$10M`) and stay guardable.
212
+ if (ch === "$") {
213
+ const m = COMPACT_MATH_PAIR.exec(text.slice(i));
214
+ if (m && !CURRENCY_SHAPED_INNER.test(m[1])) {
215
+ const end = i + m[0].length;
216
+ pushProtected(i, end);
217
+ i = end;
218
+ continue;
219
+ }
220
+ }
188
221
  i++;
189
222
  }
190
223
  if (plainStart < text.length) out.push({ code: false, text: text.slice(plainStart) });
@@ -193,10 +226,11 @@ function splitProseProtected(text: string): Segment[] {
193
226
 
194
227
  /** Split rendered markdown into prose / protected segments where a PROTECTED
195
228
  * (`code: true`) segment is any content a guard must emit verbatim: code spans,
196
- * fenced code blocks, markdown link destinations, bare autolinks, and GFM
197
- * table rows. This is the link/table-aware superset of `splitCodeSegments` that
198
- * all four #3252 guards route through. Prose segments (`code: false`) remain
199
- * guardable (including a link's `[label]` text). Deterministic, linear-time. */
229
+ * fenced code blocks, markdown link destinations, bare autolinks, GFM table
230
+ * rows, and compact `$…$` inline-math spans. This is the link/table/math-aware
231
+ * superset of `splitCodeSegments` that all the #3252 guards route through.
232
+ * Prose segments (`code: false`) remain guardable (including a link's
233
+ * `[label]` text). Deterministic, linear-time. */
200
234
  export function splitProtectedSegments(text: string): Segment[] {
201
235
  const out: Segment[] = [];
202
236
  for (const seg of splitCodeSegments(text)) {
@@ -29,6 +29,20 @@
29
29
  // a `$…$` pair if only the leading-`$digit` token is escaped — so we escape the
30
30
  // lot once the currency signal + 2-dollar threshold are met.
31
31
  //
32
+ // ── INTENTIONAL math is exempt (wire-verified 2026-08-13) ─────────────────
33
+ // Telegram's rich path renders a `$…$` pair as a native mathematical_expression
34
+ // node, and intentional math (`$x^2+y^2$`) must reach the wire byte-identical —
35
+ // an escaped `\$x^2+y^2\$` destroys a SUPPORTED construct. The discrimination
36
+ // lives in `splitProtectedSegments` (code-segments.ts): a COMPACT math span
37
+ // (whitespace-free inner, not currency-shaped) is a protected segment, so this
38
+ // guard neither counts its `$`s toward the 2+ threshold nor escapes them.
39
+ // Currency amounts always sit next to whitespace/prose (`$5M and $10M`) or have
40
+ // a digits-and-punctuation-only inner (`$5M-$10M`), so #3252-class accidental
41
+ // pairs remain fully guarded. Known residual: a SPACED math span (`$a + b$`)
42
+ // is indistinguishable from currency prose and is not exempted — it is escaped
43
+ // when the message also carries a currency signal, rendering as literal text
44
+ // (legible, not broken).
45
+ //
32
46
  // Idempotent (F5): the escape uses a negative-lookbehind (`(?<!\\)\$`) so an
33
47
  // already-escaped `\$` is never doubled to `\\$`. Running the guard twice (e.g.
34
48
  // the streaming path renders then this wrapper re-wraps) is a strict no-op the
@@ -100,7 +114,8 @@ const UNESCAPED_DOLLAR = /(?<!\\)\$/g;
100
114
  * them is digit-adjacent (a currency signal). When armed, EVERY unescaped prose
101
115
  * `$` is backslash-escaped so no two `$` can pair into a math span — this is
102
116
  * what closes the F3 trailing-`$` / `$.50` false-negatives. Code spans / fenced
103
- * blocks are never touched. Idempotent (F5) and deterministic.
117
+ * blocks AND compact intentional-math `$…$` spans (protected segments, see
118
+ * code-segments.ts) are never touched. Idempotent (F5) and deterministic.
104
119
  */
105
120
  export function guardDollarMath(text: string): string {
106
121
  if (!text.includes("$")) return text;
@@ -30,11 +30,21 @@
30
30
  // highlight -> `==…==` (Bot API 10.1 marked entity)
31
31
  // code -> `` `…` ``
32
32
  // link -> `[…](…)`
33
+ // tg-entity -> `![…](tg://…)` (Bot API date_time / custom-emoji entity)
34
+ // raw -> source bytes verbatim (never escaped) — footnote markers
35
+ // `[^1]` and definition lines `[^1]: …`, which Telegram's
36
+ // rich parser renders natively and escapeMarkdown would break
33
37
  //
34
38
  // Block
35
39
  // paragraph -> children joined; blocks separated by "\n\n"
36
40
  // heading -> `#`…`######` line
37
- // blockquote -> `> …` (expandable === true -> `**> …` expandable blockquote)
41
+ // blockquote -> `> …` on every line. `expandable === true` records that
42
+ // the SOURCE carried the legacy `**> ` marker, but it is
43
+ // NOT a distinct wire style: `**>` is MarkdownV2-only
44
+ // syntax that the rich markdown path renders as literal
45
+ // text (wire-verified 2026-08-13), so the renderer
46
+ // degrades it to a plain quote. Authors wanting a real
47
+ // collapsible use `<details><summary>…</summary>…</details>`.
38
48
  // code-block -> ```` ```lang … ``` ````
39
49
  // list -> line-per-item with `-`/`1.` markers
40
50
  // thematic-break -> `---` thematic break
@@ -109,6 +119,40 @@ export interface LinkNode extends Pos {
109
119
  children: Inline[];
110
120
  }
111
121
 
122
+ /** A Telegram rich-markdown INLINE entity written in mdast IMAGE position.
123
+ * The "Rich Markdown style" grammar (https://core.telegram.org/bots/api,
124
+ * quoted in `reference/telegram-formatting-guide.md`) lists exactly two:
125
+ *
126
+ * ![](tg://emoji?id=5368324170671202286) custom emoji
127
+ * ![22:45 tomorrow](tg://time?unix=1647531900&format=wDT) date_time
128
+ *
129
+ * (the `date_time` MessageEntity is Bot API 9.5, March 1 2026; the rich-message
130
+ * `RichTextDateTime` class is 10.1, June 11 2026 — both in the Bot API
131
+ * changelog.) `parse.ts` folds ONLY those two `tg:` hrefs into this node;
132
+ * every other image url keeps the historical demote-to-`plain` fallback,
133
+ * because an http(s) `![](…)` is a Telegram MEDIA block — "Media can be
134
+ * specified only as a separate block" (same doc) — not an inline entity, and
135
+ * switchroom does not emit media blocks.
136
+ *
137
+ * `label` is the DECODED alternative text (mdast `image.alt`, empty for the
138
+ * emoji form); `href` is the `tg:` URL. Both are re-escaped on render, same
139
+ * as `LinkNode`. */
140
+ export interface TgEntityNode extends Pos {
141
+ type: "tg-entity";
142
+ label: string;
143
+ href: string;
144
+ }
145
+
146
+ /** Verbatim wire passthrough: the node's SOURCE bytes are already the exact
147
+ * syntax Telegram's rich parser expects, so the renderer must emit them
148
+ * unescaped (escapeMarkdown would corrupt them). Used for GFM footnote
149
+ * reference markers (`[^1]`) and footnote definition lines (`[^1]: …`) —
150
+ * both natively supported on the rich path (wire-verified 2026-08-13). */
151
+ export interface RawNode extends Pos {
152
+ type: "raw";
153
+ text: string;
154
+ }
155
+
112
156
  export type Inline =
113
157
  | PlainNode
114
158
  | BoldNode
@@ -118,7 +162,9 @@ export type Inline =
118
162
  | SpoilerNode
119
163
  | HighlightNode
120
164
  | CodeNode
121
- | LinkNode;
165
+ | LinkNode
166
+ | TgEntityNode
167
+ | RawNode;
122
168
 
123
169
  // ---------------------------------------------------------------------------
124
170
  // Block nodes
@@ -139,7 +185,11 @@ export interface HeadingNode extends Pos {
139
185
  export interface BlockquoteNode extends Pos {
140
186
  type: "blockquote";
141
187
  children: Block[];
142
- /** Telegram <blockquote expandable>. Always false in Increment 1 — see parse.ts. */
188
+ /** True when the source carried the LEGACY switchroom `**> ` expandable
189
+ * marker (see parse.ts markExpandableQuotes). Records authoring intent
190
+ * only: `**>` is MarkdownV2 syntax with no rich-markdown equivalent
191
+ * (wire-verified 2026-08-13), so the renderer emits a plain `> ` quote
192
+ * either way. */
143
193
  expandable: boolean;
144
194
  }
145
195
 
@@ -37,12 +37,18 @@
37
37
  // own reading of the delimiters.
38
38
  //
39
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
40
+ // The IR carries `expandable: boolean` for the LEGACY switchroom `**> `
41
+ // expandable-quote encoding. `**>` was believed to be the Bot API 10.1
42
+ // expandable-blockquote marker; wire probes (2026-08-13) proved it is
43
+ // MarkdownV2-only syntax that the rich markdown path renders as LITERAL
44
+ // `**>` text, so `render.ts` no longer emits it — an expandable node renders
45
+ // as a plain `> ` quote. Recognition here is kept as INPUT REPAIR: agent
46
+ // output (and Hindsight memories) trained on the old floor card still
47
+ // contains `**> ` quotes, and without this rewrite such a line would reach
48
+ // the wire as a broken literal-`**>` paragraph. micromark does NOT
49
+ // understand `**> ` as a blockquote — the leading `**` makes the line a
50
+ // paragraph with an unclosed strong-emphasis run — so this module
51
+ // pre-transforms each
46
52
  // `**>` marker into a plain ` >` marker of IDENTICAL length (`**` → two
47
53
  // spaces) before handing the text to mdast. Length preservation keeps every
48
54
  // UTF-16 source offset (and therefore the never-lose-text round-trip
@@ -90,15 +96,35 @@ function slice(source: string, node: MdastNode): string {
90
96
  return source.slice(start, end);
91
97
  }
92
98
 
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
99
+ /** The LEGACY switchroom expandable-blockquote marker: `**>` at the very
100
+ * start of a line (column 0). The render path no longer emits it (it is
101
+ * MarkdownV2-only syntax, not rich markdown — see `render.ts`
102
+ * renderBlockquote), but it is still RECOGNISED on input so a legacy `**> `
103
+ * line is repaired into a real blockquote instead of shipping as literal
104
+ * `**>` text. Matching only at column 0 keeps
97
105
  * the length-preserving rewrite (`**` → two spaces) inside CommonMark's
98
106
  * 3-space blockquote-indent budget — allowing leading indent here would push
99
107
  * the rewritten ` >` past 3 spaces and turn it into an indented code block. */
100
108
  const EXPANDABLE_MARKER_RE = /^\*\*>/;
101
109
 
110
+ /** The `tg:` hrefs Telegram's "Rich Markdown style" grammar accepts in IMAGE
111
+ * position — `![label](tg://…)`. Exactly two are documented
112
+ * (https://core.telegram.org/bots/api): `tg://emoji?id=…` (custom emoji) and
113
+ * `tg://time?unix=…[&format=…]` (the `date_time` entity). Deliberately an
114
+ * ALLOWLIST rather than a bare `tg:` scheme test: an undocumented `tg://…` in
115
+ * image position is not known-good syntax, and demoting it to literal text
116
+ * (the historical behaviour) is safer than shipping a construct Telegram may
117
+ * parse-reject. */
118
+ const TG_INLINE_ENTITY_HREFS = ["tg://emoji", "tg://time"] as const;
119
+
120
+ /** True when an mdast `image` url is one of the documented inline `tg:`
121
+ * entities. Scheme/host comparison is case-insensitive (URLs are), but the
122
+ * ORIGINAL href is what gets re-emitted — we never rewrite the author's bytes. */
123
+ function isTgInlineEntityHref(href: string): boolean {
124
+ const h = href.toLowerCase();
125
+ return TG_INLINE_ENTITY_HREFS.some((base) => h === base || h.startsWith(`${base}?`));
126
+ }
127
+
102
128
  /** Pre-transform expandable-blockquote markers so mdast can parse them as
103
129
  * ordinary blockquotes, WITHOUT shifting any source offset. Each line that
104
130
  * opens with `**>` has its two `*` characters replaced by two spaces
@@ -170,8 +196,30 @@ function foldInline(node: PhrasingContent, source: string): Inline {
170
196
  children: foldInlineChildren(node, source),
171
197
  ...pos(node),
172
198
  };
173
- // Not in the palette (break, image, html, footnoteReference, …): keep the
174
- // raw source text so no content is lost.
199
+ case "image": {
200
+ // GFM's image syntax doubles as Telegram's INLINE-entity syntax:
201
+ // `![22:45 tomorrow](tg://time?unix=…&format=…)` (date_time) and
202
+ // `![](tg://emoji?id=…)` (custom emoji). Fold those two into a
203
+ // `tg-entity` node so the renderer re-emits the construct verbatim
204
+ // instead of escaping the brackets to literal text. Every OTHER image
205
+ // url — notably the http(s) MEDIA forms, which Telegram accepts only as
206
+ // a SEPARATE block — falls through to the demote-to-`plain` default
207
+ // below, unchanged.
208
+ if (isTgInlineEntityHref(node.url)) {
209
+ return { type: "tg-entity", label: node.alt ?? "", href: node.url, ...pos(node) };
210
+ }
211
+ return { type: "plain", text: slice(source, node), ...pos(node) };
212
+ }
213
+ // GFM footnote reference marker (`[^1]`): natively supported by Telegram's
214
+ // rich markdown path (wire-verified 2026-08-13 — renders as the full
215
+ // superscript + anchor + reference_link machinery). The source bytes ARE
216
+ // the wire syntax, so fold to a `raw` node the renderer emits verbatim;
217
+ // a `plain` node would be escapeMarkdown'd (`\[^1\]`) and break the
218
+ // construct on the wire.
219
+ case "footnoteReference":
220
+ return { type: "raw", text: slice(source, node), ...pos(node) };
221
+ // Not in the palette (break, non-`tg:` image, html, …): keep the raw
222
+ // source text so no content is lost.
175
223
  default:
176
224
  return { type: "plain", text: slice(source, node), ...pos(node) };
177
225
  }
@@ -332,8 +380,19 @@ function foldBlock(
332
380
  ...pos(node),
333
381
  };
334
382
  }
335
- // Not in the palette (html, definition, footnoteDefinition, …): degrade to
336
- // a paragraph carrying the raw source slice so no content is dropped.
383
+ // GFM footnote DEFINITION (`[^1]: body`): natively supported on the wire
384
+ // (2026-08-13 probe — pairs with the reference marker into footer/anchor
385
+ // nodes). Emit the raw source slice VERBATIM via a `raw` inline: a `plain`
386
+ // fold would escapeMarkdown the `[`/`]` (`\[^1\]: body`) and orphan the
387
+ // reference.
388
+ case "footnoteDefinition":
389
+ return {
390
+ type: "paragraph",
391
+ children: [{ type: "raw", text: slice(source, node), ...pos(node) }],
392
+ ...pos(node),
393
+ };
394
+ // Not in the palette (html, definition, …): degrade to a paragraph
395
+ // carrying the raw source slice so no content is dropped.
337
396
  default:
338
397
  return {
339
398
  type: "paragraph",
@@ -16,10 +16,11 @@
16
16
  // "HTML" }`. There is no HTML anywhere on the current outbound path (see
17
17
  // `reference/telegram-formatting-guide.md`). This renderer therefore targets
18
18
  // the ACTUAL contract: GFM markdown with the Bot API 10.1 extensions
19
- // documented in the formatting guide (expandable blockquote via `**> `,
20
- // spoiler via `||…||`, GFM pipe tables, etc). This module is NOT wired into
21
- // the live send path yet — that is a later increment, per the RFC's phased
22
- // rollout (rich rendering stays gated off by default until then).
19
+ // documented in the formatting guide (spoiler via `||…||`, GFM pipe tables,
20
+ // `<details>` collapsibles passed through as HTML, etc). NOTE: `**> ` is NOT
21
+ // part of that contract — it is MarkdownV2-only syntax the rich path renders
22
+ // as literal text (wire-verified 2026-08-13); this renderer no longer emits
23
+ // it anywhere (see renderBlockquote).
23
24
  //
24
25
  // Round-trip note: `parse.ts` folds inline text (`PlainNode.text`,
25
26
  // `CodeNode.text`, `code-block` `text`, link `href`) into DECODED strings —
@@ -59,6 +60,14 @@ interface InlineCtx {
59
60
  inTableCell?: boolean;
60
61
  }
61
62
 
63
+ /** Collapse any whitespace run containing a newline down to a single space.
64
+ * Used for a `tg-entity` label: `![` … `]` must stay on ONE line or the
65
+ * construct is not an entity any more. mdast decodes a soft line break inside
66
+ * the alt text to a literal `\n`, which is exactly the case this flattens. */
67
+ function collapseLabelBreaks(label: string): string {
68
+ return label.replace(/[ \t]*\r?\n[ \t\r\n]*/g, " ");
69
+ }
70
+
62
71
  function renderInline(node: Inline, ctx: InlineCtx = {}): string {
63
72
  switch (node.type) {
64
73
  case "plain":
@@ -91,6 +100,25 @@ function renderInline(node: Inline, ctx: InlineCtx = {}): string {
91
100
  // Escape the href so a literal `)` in the URL can't terminate the
92
101
  // destination early and break the link (F3).
93
102
  return `[${renderInlineChildren(node.children, ctx)}](${escapeLinkHref(node.href)})`;
103
+ case "tg-entity":
104
+ // `![label](tg://time?unix=…&format=…)` / `![](tg://emoji?id=…)`.
105
+ // The label is PROSE (the alternative text Telegram shows when it can't
106
+ // render the entity), so it is escaped exactly like a `plain` node —
107
+ // without that, a label containing `]` or a formatting delimiter
108
+ // (`![see [22:45]](tg://time?…)`) closes the label early and smuggles
109
+ // raw bracket syntax past the renderer. A newline inside the label would
110
+ // split the construct across lines, so runs of whitespace spanning one
111
+ // are collapsed to a single space first. The href gets the same
112
+ // `escapeLinkHref` treatment a link's does (a no-op for the paren-free
113
+ // `tg:` URLs in practice, load-bearing if one ever carries a `)`).
114
+ return `![${escapeMarkdown(collapseLabelBreaks(node.label))}](${escapeLinkHref(node.href)})`;
115
+ case "raw":
116
+ // Verbatim wire passthrough — the source bytes ARE the wire syntax
117
+ // (footnote reference markers `[^1]` / definition lines `[^1]: …`,
118
+ // which Telegram's rich parser renders natively; escapeMarkdown would
119
+ // escape their `[`/`]` and break the construct — the exact bug this
120
+ // node type exists to prevent).
121
+ return node.text;
94
122
  default: {
95
123
  // Exhaustiveness guard — the IR union is closed; a new variant must be
96
124
  // handled above rather than silently dropped.
@@ -118,17 +146,20 @@ function prefixLines(text: string, prefix: string): string {
118
146
 
119
147
  function renderBlockquote(node: BlockquoteNode): string {
120
148
  const inner = renderBlocks(node.children);
121
- // Bot API 10.1 expandable blockquote: `**> ` on the first quoted line.
122
- // Plain blockquote: `> ` on every line.
123
- if (node.expandable) {
124
- const lines = inner.split("\n");
125
- return lines
126
- .map((line, i) => {
127
- const marker = i === 0 ? "**> " : "> ";
128
- return line.length > 0 ? `${marker}${line}` : marker.trimEnd();
129
- })
130
- .join("\n");
131
- }
149
+ // Always a plain `> ` blockquote — including for `expandable: true` nodes.
150
+ //
151
+ // This renderer USED to emit `**> ` on the first line of an expandable
152
+ // quote, believing it to be the Bot API 10.1 expandable-blockquote marker.
153
+ // That belief was falsified by raw sendRichMessage wire probes (2026-08-13):
154
+ // `**>` is MarkdownV2 syntax; the rich markdown path renders it as a LITERAL
155
+ // `**> …` paragraph followed by a detached plain quote. The `expandable`
156
+ // flag is retained on the IR (parse.ts still repairs legacy `**>` input into
157
+ // a real blockquote instead of letting the literal `**>` reach the wire),
158
+ // but it is NOT a distinct wire style — the faithful degradation is a plain
159
+ // quote, which shows the full content. An author who wants a genuine
160
+ // collapsible writes `<details><summary>…</summary>…</details>`, which the
161
+ // rich path renders natively (typed `details` node, wire-verified) and which
162
+ // passes through this pipeline verbatim.
132
163
  return prefixLines(inner, "> ");
133
164
  }
134
165
 
@@ -299,6 +330,13 @@ export const SUPPORTED_INLINE = [
299
330
  "highlight",
300
331
  "code",
301
332
  "link",
333
+ // `![…](tg://time?…)` / `![…](tg://emoji?id=…)` — the two inline `tg:`
334
+ // entities in Telegram's Rich Markdown grammar. Emitted verbatim (label and
335
+ // href re-escaped); any OTHER image url stays a `plain` node.
336
+ "tg-entity",
337
+ // Verbatim passthrough for constructs whose SOURCE bytes are the wire syntax
338
+ // (footnote markers/definitions). Never escaped, never rewritten.
339
+ "raw",
302
340
  ] as const;
303
341
 
304
342
  export const SUPPORTED_BLOCK = [