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.
- package/bin/tmp-reaper.sh +234 -0
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +2 -2
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +3520 -2782
- package/dist/host-control/main.js +177 -13
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +6 -5
- package/profiles/_base/start.sh.hbs +115 -0
- package/profiles/_shared/local-time.md.hbs +6 -0
- package/profiles/default/CLAUDE.md.hbs +0 -12
- package/skills/switchroom-architecture/telegram.md +12 -10
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/README.md +3 -1
- package/telegram-plugin/dist/gateway/gateway.js +1164 -547
- package/telegram-plugin/format.ts +12 -4
- package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
- package/telegram-plugin/gateway/approval-hold.ts +32 -1
- package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
- package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
- package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
- package/telegram-plugin/gateway/gateway.ts +12 -10
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
- package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
- package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/render/code-segments.ts +38 -4
- package/telegram-plugin/render/dollar-math-guard.ts +16 -1
- package/telegram-plugin/render/ir.ts +53 -3
- package/telegram-plugin/render/parse.ts +73 -14
- package/telegram-plugin/render/render.ts +53 -15
- package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
- package/telegram-plugin/rich-send.ts +22 -7
- package/telegram-plugin/shared/bot-runtime.ts +3 -2
- package/telegram-plugin/telegraph.ts +6 -4
- package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
- package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
- package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
- package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
- package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
- package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
- package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
- package/telegram-plugin/tests/render/parse.test.ts +30 -5
- package/telegram-plugin/tests/render/render.test.ts +9 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
- package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
- package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
- package/telegram-plugin/tests/telegraph.test.ts +1 -1
- 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 {
|
|
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.
|
|
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,
|
|
197
|
-
*
|
|
198
|
-
* all
|
|
199
|
-
* guardable (including a link's
|
|
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
|
|
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 -> `` (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 -> `> …`
|
|
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
|
+
*  custom emoji
|
|
127
|
+
*  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
|
-
/**
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
|
94
|
-
* a line (column 0).
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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 — ``. 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
|
-
|
|
174
|
-
|
|
199
|
+
case "image": {
|
|
200
|
+
// GFM's image syntax doubles as Telegram's INLINE-entity syntax:
|
|
201
|
+
// `` (date_time) and
|
|
202
|
+
// `` (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
|
-
//
|
|
336
|
-
//
|
|
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 (
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
+
// `` / ``.
|
|
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 `})`;
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
// `` / `` — 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 = [
|