switchroom 0.19.9 → 0.19.10
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/dist/cli/switchroom.js +62 -28
- package/dist/host-control/main.js +3 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +49 -4
- package/telegram-plugin/render/unsupported-token-guard.ts +121 -0
- package/telegram-plugin/rich-send.ts +9 -0
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +125 -0
- package/skills/telegram-formatting/SKILL.md +0 -147
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.19.
|
|
2123
|
+
var VERSION = "0.19.10", COMMIT_SHA = "d88584dd";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -23861,8 +23861,7 @@ function getBuiltinDefaultSkillEntries() {
|
|
|
23861
23861
|
"switchroom-health",
|
|
23862
23862
|
"switchroom-runtime",
|
|
23863
23863
|
"mental-model-curator",
|
|
23864
|
-
"dev-protocol"
|
|
23865
|
-
"telegram-formatting"
|
|
23864
|
+
"dev-protocol"
|
|
23866
23865
|
];
|
|
23867
23866
|
return [
|
|
23868
23867
|
...anthropic.map((key) => ({ key, optOutKey: key, source: "anthropic" })),
|
|
@@ -24077,6 +24076,38 @@ function reconcileAgentDefaultSkills(agentDir, optOuts = {}, defaults = getBuilt
|
|
|
24077
24076
|
result.conflicts.push(entry.key);
|
|
24078
24077
|
}
|
|
24079
24078
|
}
|
|
24079
|
+
const defaultKeys = new Set(defaults.map((d) => d.key));
|
|
24080
|
+
const legitimate = new Set(defaultKeys);
|
|
24081
|
+
try {
|
|
24082
|
+
for (const poolName of readdirSync4(poolDir)) {
|
|
24083
|
+
if (!poolName.startsWith("switchroom-"))
|
|
24084
|
+
continue;
|
|
24085
|
+
if (defaultKeys.has(poolName))
|
|
24086
|
+
continue;
|
|
24087
|
+
try {
|
|
24088
|
+
const st = lstatSync2(join6(poolDir, poolName));
|
|
24089
|
+
if (st.isDirectory() && existsSync8(join6(poolDir, poolName, "SKILL.md"))) {
|
|
24090
|
+
legitimate.add(poolName);
|
|
24091
|
+
}
|
|
24092
|
+
} catch {}
|
|
24093
|
+
}
|
|
24094
|
+
} catch {}
|
|
24095
|
+
let dirEntries = [];
|
|
24096
|
+
try {
|
|
24097
|
+
dirEntries = readdirSync4(targetDir);
|
|
24098
|
+
} catch {}
|
|
24099
|
+
for (const linkName of dirEntries) {
|
|
24100
|
+
if (legitimate.has(linkName))
|
|
24101
|
+
continue;
|
|
24102
|
+
const dest = join6(targetDir, linkName);
|
|
24103
|
+
if (!isOwnedBundledLink(dest, poolDir))
|
|
24104
|
+
continue;
|
|
24105
|
+
try {
|
|
24106
|
+
rmSync2(dest, { force: true });
|
|
24107
|
+
result.pruned.push(linkName);
|
|
24108
|
+
result.changed = true;
|
|
24109
|
+
} catch {}
|
|
24110
|
+
}
|
|
24080
24111
|
return result;
|
|
24081
24112
|
}
|
|
24082
24113
|
var warnedMissingPool, warnedMissingDefault;
|
|
@@ -29475,31 +29506,34 @@ You're writing for a phone screen in Telegram. Every reply renders as rich Markd
|
|
|
29475
29506
|
|
|
29476
29507
|
- **Short answers (a line or two): plain prose, no formatting.** "on it, pulling the
|
|
29477
29508
|
logs now" is already perfect. No bold, no bullets, no headings.
|
|
29478
|
-
- **Default: light structure.** Bold ONLY the one key fact or answer, never more.
|
|
29479
|
-
|
|
29480
|
-
|
|
29481
|
-
|
|
29482
|
-
-
|
|
29483
|
-
|
|
29484
|
-
|
|
29485
|
-
|
|
29486
|
-
|
|
29487
|
-
|
|
29488
|
-
|
|
29489
|
-
|
|
29490
|
-
|
|
29491
|
-
|
|
29492
|
-
|
|
29493
|
-
|
|
29494
|
-
|
|
29495
|
-
|
|
29496
|
-
|
|
29497
|
-
|
|
29498
|
-
|
|
29499
|
-
|
|
29500
|
-
|
|
29501
|
-
|
|
29502
|
-
|
|
29509
|
+
- **Default: light structure.** Bold ONLY the one key fact or answer, never more. A
|
|
29510
|
+
list only for 3+ genuinely parallel items the reader will scan or compare; two items
|
|
29511
|
+
or a flowing thought stay prose. \`code spans\` for identifiers: filenames, commands,
|
|
29512
|
+
config keys, error codes (tap-to-copy). Wrap dynamic identifiers in backticks:
|
|
29513
|
+
code-span content is literal, so it never needs escaping. Links as \`[label](url)\`,
|
|
29514
|
+
never bare pasted URLs mid-prose.
|
|
29515
|
+
- **Long answers may add the rich constructs, but only when they cut the reader's
|
|
29516
|
+
effort:** a GFM pipe table for real 2-D data (rows x columns); headings only in a
|
|
29517
|
+
multi-section answer; \`>\` for quoted text; fenced code blocks ALWAYS with a language
|
|
29518
|
+
hint (\`\`\`diff, \`\`\`json, \`\`\`bash \u2014 bare fence only for non-code fixed-width output);
|
|
29519
|
+
and the flagship \u2014 the **expandable blockquote** \`**> first line\` + \`> continuation\`
|
|
29520
|
+
for a long quote, stack trace, or detailed aside the reader can collapse. Use it
|
|
29521
|
+
whenever a bulky supporting block would otherwise dominate the message.
|
|
29522
|
+
|
|
29523
|
+
Renders wrong on this path \u2014 never emit: underline (\`__x__\` renders as bold),
|
|
29524
|
+
\`^sup^\`/\`~sub~\`, \`$math$\`, \`<details>\`, footnotes \`[^1]\`. Write "squared", not \`x^2^\`.
|
|
29525
|
+
|
|
29526
|
+
The framework normalizes mechanics in code on every outbound message: block spacing,
|
|
29527
|
+
em/en dashes, and \`\u2022\` bullet markers are
|
|
29528
|
+
rewritten deterministically; unsupported tokens (\`^highlight^\`, \`$math$\`, \`<details>\`,
|
|
29529
|
+
footnotes) are repaired; long messages are chunked safely at 32768 chars (fences and
|
|
29530
|
+
table rows never bisected); over-bolded messages get their bold stripped. Don't
|
|
29531
|
+
hand-tune spacing or fight it \u2014 write the content, the gateway makes typography
|
|
29532
|
+
consistent. Long before the cap, ask whether a wall of text is the right answer at all.
|
|
29533
|
+
|
|
29534
|
+
Structure exists for the reader, not the writer: a two-item bullet list is worse than
|
|
29535
|
+
a sentence, a heading on a three-line reply is noise. When in doubt, shorter and
|
|
29536
|
+
plainer wins.
|
|
29503
29537
|
|
|
29504
29538
|
Every turn that answers a user message ends with a user-visible \`reply\`
|
|
29505
29539
|
\u2014 Telegram is all the user sees; your terminal output
|
|
@@ -26664,7 +26664,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
|
|
|
26664
26664
|
import { dirname as dirname5, join as join7 } from "node:path";
|
|
26665
26665
|
|
|
26666
26666
|
// src/build-info.ts
|
|
26667
|
-
var VERSION = "0.19.
|
|
26667
|
+
var VERSION = "0.19.10";
|
|
26668
26668
|
|
|
26669
26669
|
// src/cli/resolve-version.ts
|
|
26670
26670
|
function readPackageVersion() {
|
|
@@ -28449,7 +28449,8 @@ class HostdServer {
|
|
|
28449
28449
|
return [
|
|
28450
28450
|
join10(root, "profiles"),
|
|
28451
28451
|
join10(root, "profiles", "default"),
|
|
28452
|
-
join10(root, "vendor", "hindsight-memory")
|
|
28452
|
+
join10(root, "vendor", "hindsight-memory"),
|
|
28453
|
+
join10(root, "skills")
|
|
28453
28454
|
].filter((p) => !existsSync11(p));
|
|
28454
28455
|
}
|
|
28455
28456
|
applyAssetPreflight(request_id, started) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.19.
|
|
4
|
+
"version": "0.19.10",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -8755,9 +8755,51 @@ var init_inline_pairs_guard = __esm(() => {
|
|
|
8755
8755
|
SPOILER_OP = /(?<=\w)\|\|(?=\w)/g;
|
|
8756
8756
|
});
|
|
8757
8757
|
|
|
8758
|
+
// render/unsupported-token-guard.ts
|
|
8759
|
+
function toExpandableBlockquote(title, body) {
|
|
8760
|
+
const lines = [];
|
|
8761
|
+
const t = title.trim();
|
|
8762
|
+
if (t)
|
|
8763
|
+
lines.push(t);
|
|
8764
|
+
for (const raw of body.split(`
|
|
8765
|
+
`)) {
|
|
8766
|
+
const line = raw.trimEnd();
|
|
8767
|
+
if (line.trim() === "" && lines.length === 0)
|
|
8768
|
+
continue;
|
|
8769
|
+
lines.push(line);
|
|
8770
|
+
}
|
|
8771
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === "")
|
|
8772
|
+
lines.pop();
|
|
8773
|
+
if (lines.length === 0)
|
|
8774
|
+
return "";
|
|
8775
|
+
return lines.map((line, i) => i === 0 ? `**> ${line}` : `> ${line}`).join(`
|
|
8776
|
+
`);
|
|
8777
|
+
}
|
|
8778
|
+
function repairProse(text) {
|
|
8779
|
+
let out = text;
|
|
8780
|
+
out = out.replace(DETAILS_BLOCK, (_m, summary, body) => toExpandableBlockquote(summary ?? "", body ?? ""));
|
|
8781
|
+
out = out.replace(ORPHAN_TAGS, "");
|
|
8782
|
+
out = out.replace(CARET_PAIR, (_m, inner) => inner);
|
|
8783
|
+
out = out.replace(FOOTNOTE_MARKER, "");
|
|
8784
|
+
return out;
|
|
8785
|
+
}
|
|
8786
|
+
function guardUnsupportedTokens(text) {
|
|
8787
|
+
if (!/[\^]|<details|<\/details|<summary/i.test(text))
|
|
8788
|
+
return text;
|
|
8789
|
+
return splitProtectedSegments(text).map((seg) => seg.code ? seg.text : repairProse(seg.text)).join("");
|
|
8790
|
+
}
|
|
8791
|
+
var CARET_PAIR, FOOTNOTE_MARKER, DETAILS_BLOCK, ORPHAN_TAGS;
|
|
8792
|
+
var init_unsupported_token_guard = __esm(() => {
|
|
8793
|
+
CARET_PAIR = /\^([A-Za-z0-9]+)\^/g;
|
|
8794
|
+
FOOTNOTE_MARKER = /\[\^\d+\](?!:)/g;
|
|
8795
|
+
DETAILS_BLOCK = /<details[^>]*>\s*(?:<summary[^>]*>([\s\S]*?)<\/summary>)?([\s\S]*?)<\/details>/gi;
|
|
8796
|
+
ORPHAN_TAGS = /<\/?(?:details|summary)[^>]*>/gi;
|
|
8797
|
+
});
|
|
8798
|
+
|
|
8758
8799
|
// rich-send.ts
|
|
8759
8800
|
function guardAccidentalFormatting(markdown) {
|
|
8760
8801
|
let out = markdown;
|
|
8802
|
+
out = guardUnsupportedTokens(out);
|
|
8761
8803
|
out = guardAccidentalEmphasis(out);
|
|
8762
8804
|
out = guardAccidentalHeading(out);
|
|
8763
8805
|
out = guardAccidentalBlockConstructs(out);
|
|
@@ -8788,6 +8830,7 @@ var init_rich_send = __esm(() => {
|
|
|
8788
8830
|
init_emphasis_guard();
|
|
8789
8831
|
init_line_start_guard();
|
|
8790
8832
|
init_inline_pairs_guard();
|
|
8833
|
+
init_unsupported_token_guard();
|
|
8791
8834
|
import_grammy = __toESM(require_mod2(), 1);
|
|
8792
8835
|
});
|
|
8793
8836
|
|
|
@@ -73578,9 +73621,11 @@ init_dollar_math_guard();
|
|
|
73578
73621
|
init_emphasis_guard();
|
|
73579
73622
|
init_line_start_guard();
|
|
73580
73623
|
init_inline_pairs_guard();
|
|
73624
|
+
init_unsupported_token_guard();
|
|
73581
73625
|
var import_grammy9 = __toESM(require_mod2(), 1);
|
|
73582
73626
|
function guardAccidentalFormatting2(markdown) {
|
|
73583
73627
|
let out = markdown;
|
|
73628
|
+
out = guardUnsupportedTokens(out);
|
|
73584
73629
|
out = guardAccidentalEmphasis(out);
|
|
73585
73630
|
out = guardAccidentalHeading(out);
|
|
73586
73631
|
out = guardAccidentalBlockConstructs(out);
|
|
@@ -93569,10 +93614,10 @@ function startGatewayHeartbeat(stateDir, intervalMs = GATEWAY_HEARTBEAT_INTERVAL
|
|
|
93569
93614
|
}
|
|
93570
93615
|
|
|
93571
93616
|
// ../src/build-info.ts
|
|
93572
|
-
var VERSION = "0.19.
|
|
93573
|
-
var COMMIT_SHA = "
|
|
93574
|
-
var COMMIT_DATE = "2026-07-
|
|
93575
|
-
var LATEST_PR =
|
|
93617
|
+
var VERSION = "0.19.10";
|
|
93618
|
+
var COMMIT_SHA = "d88584dd";
|
|
93619
|
+
var COMMIT_DATE = "2026-07-21T11:39:08Z";
|
|
93620
|
+
var LATEST_PR = 3495;
|
|
93576
93621
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
93577
93622
|
|
|
93578
93623
|
// gateway/boot-version.ts
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Outbound repair of markdown tokens Telegram's rich GFM parser (Bot API 10.1)
|
|
2
|
+
// CANNOT render, so the reader would otherwise see literal noise glyphs.
|
|
3
|
+
//
|
|
4
|
+
// ── Root cause ───────────────────────────────────────────────────────────
|
|
5
|
+
// Assistant replies are composed by a model that habitually emits constructs
|
|
6
|
+
// from OTHER surfaces (GitHub / Obsidian / LaTeX): caret highlight/superscript
|
|
7
|
+
// `^…^`, footnote markers `[^1]`, and HTML `<details><summary>` collapsibles.
|
|
8
|
+
// None of these are part of Telegram's rich markdown; they degrade to literal
|
|
9
|
+
// carets, stray `[^1]`, and raw `<details>` tags on the reader's screen — the
|
|
10
|
+
// worst kind of consistency failure (a broken glyph in chat). The resident
|
|
11
|
+
// floor card tells the model not to emit them, but prompt discipline is not a
|
|
12
|
+
// guarantee. This guard makes the repair deterministic at send time.
|
|
13
|
+
//
|
|
14
|
+
// ── What it repairs (deterministic, pure string transform) ─────────────────
|
|
15
|
+
// • `<details><summary>Title</summary>body</details>` → a Telegram EXPANDABLE
|
|
16
|
+
// blockquote (`**> Title` first line + `> …` continuation) — the native
|
|
17
|
+
// equivalent of a collapsible. `<details>` without a `<summary>` folds the
|
|
18
|
+
// whole body into an expandable blockquote. Any orphan `<details>` /
|
|
19
|
+
// `</details>` / `<summary>` tags left over are stripped.
|
|
20
|
+
// • `^highlight^` / `x^2^` caret pairs → the inner text, carets removed
|
|
21
|
+
// (Telegram has no highlight/superscript; the carets render literally).
|
|
22
|
+
// • Footnote reference markers `[^id]` → removed (Telegram has no footnotes).
|
|
23
|
+
// A footnote DEFINITION line `[^id]: …` is left alone (the `]:` lookahead).
|
|
24
|
+
//
|
|
25
|
+
// ── What it deliberately does NOT touch ────────────────────────────────────
|
|
26
|
+
// • `$…$` math: already neutralised deterministically upstream by
|
|
27
|
+
// `guardDollarMath` (it backslash-escapes `$` so the pair can never typeset
|
|
28
|
+
// as math — the reader sees literal `$`). Re-processing `$` here would
|
|
29
|
+
// double-process and risk corrupting currency prose, so this guard leaves
|
|
30
|
+
// `$` untouched by design. Math repair is COVERED, just in the sibling guard.
|
|
31
|
+
// • `~sub~` tilde pairs: the strikethrough/tilde trigger is owned by
|
|
32
|
+
// `guardAccidentalInlinePairs` (disjoint char set). This guard never
|
|
33
|
+
// inspects or inserts `~`.
|
|
34
|
+
// • `__underline__`: renders as BOLD in Telegram — legible, not broken — so it
|
|
35
|
+
// is left as-is (the floor card asks the model to avoid it, but there is no
|
|
36
|
+
// glyph-level failure to repair).
|
|
37
|
+
//
|
|
38
|
+
// Code spans / fenced blocks / link destinations / table rows are emitted
|
|
39
|
+
// verbatim (shared `splitProtectedSegments`). A strict no-op for any body
|
|
40
|
+
// without one of these tokens, and idempotent (running twice is a no-op the
|
|
41
|
+
// second time — a repaired blockquote contains no `<details>`, a stripped caret
|
|
42
|
+
// pair contains no `^`). Safe to compose once per send alongside the #3252
|
|
43
|
+
// accidental-formatting guards.
|
|
44
|
+
|
|
45
|
+
import { splitProtectedSegments } from "./code-segments.js";
|
|
46
|
+
|
|
47
|
+
/** Paired caret highlight / superscript: `^text^` where the inner run is a
|
|
48
|
+
* single ALPHANUMERIC token (no newline, no whitespace, no nested caret, and
|
|
49
|
+
* crucially no expression punctuation like `+ = -`). Non-greedy inner run.
|
|
50
|
+
* Replaced by the inner text.
|
|
51
|
+
*
|
|
52
|
+
* The alphanumeric-only constraint is deliberate and NARROWER than a plain
|
|
53
|
+
* "non-caret non-space" run: it distinguishes a genuine single superscript /
|
|
54
|
+
* highlight token (`x^2^`, `^highlighted^`) from a whitespace-free math
|
|
55
|
+
* expression whose carets are INDEPENDENT exponents (`a^2+b^2=c^2`). With a
|
|
56
|
+
* permissive inner run the first two carets of `a^2+b^2=c^2` pair up (`^2+b^`)
|
|
57
|
+
* and get stripped, mangling the math; requiring the inner run to be pure
|
|
58
|
+
* alphanumerics means `^2+b^` never matches (the `+` breaks the run), so
|
|
59
|
+
* `a^2+b^2=c^2`, `2^8`, and `x^n` all pass through untouched. */
|
|
60
|
+
const CARET_PAIR = /\^([A-Za-z0-9]+)\^/g;
|
|
61
|
+
|
|
62
|
+
/** Footnote reference marker `[^N]` (digits only) NOT immediately followed by
|
|
63
|
+
* `:` (which would make it a footnote DEFINITION line we leave intact). Removed
|
|
64
|
+
* entirely. Restricting the id to digits keeps this off in-prose bracket
|
|
65
|
+
* literals like `array[^index]` and regex-ish `[^/]`, which are NOT footnotes
|
|
66
|
+
* and would otherwise be silently eaten. */
|
|
67
|
+
const FOOTNOTE_MARKER = /\[\^\d+\](?!:)/g;
|
|
68
|
+
|
|
69
|
+
/** `<details>…</details>` with an optional leading `<summary>…</summary>`.
|
|
70
|
+
* Dot-all via `[\s\S]`; non-greedy so adjacent blocks don't merge. */
|
|
71
|
+
const DETAILS_BLOCK =
|
|
72
|
+
/<details[^>]*>\s*(?:<summary[^>]*>([\s\S]*?)<\/summary>)?([\s\S]*?)<\/details>/gi;
|
|
73
|
+
|
|
74
|
+
/** Orphan collapsible tags left after DETAILS_BLOCK (malformed / unpaired). */
|
|
75
|
+
const ORPHAN_TAGS = /<\/?(?:details|summary)[^>]*>/gi;
|
|
76
|
+
|
|
77
|
+
/** Fold `title` + `body` into a Telegram expandable blockquote: the first
|
|
78
|
+
* emitted line carries the `**> ` marker (switchroom's expandable-blockquote
|
|
79
|
+
* encoding — see parse.ts), every subsequent line a plain `> `. */
|
|
80
|
+
function toExpandableBlockquote(title: string, body: string): string {
|
|
81
|
+
const lines: string[] = [];
|
|
82
|
+
const t = title.trim();
|
|
83
|
+
if (t) lines.push(t);
|
|
84
|
+
for (const raw of body.split("\n")) {
|
|
85
|
+
const line = raw.trimEnd();
|
|
86
|
+
// Collapse leading/trailing blank lines but keep interior structure.
|
|
87
|
+
if (line.trim() === "" && lines.length === 0) continue;
|
|
88
|
+
lines.push(line);
|
|
89
|
+
}
|
|
90
|
+
// Trim trailing blanks.
|
|
91
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
92
|
+
if (lines.length === 0) return "";
|
|
93
|
+
return lines
|
|
94
|
+
.map((line, i) => (i === 0 ? `**> ${line}` : `> ${line}`))
|
|
95
|
+
.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Repair unsupported tokens in a single PROSE segment. */
|
|
99
|
+
function repairProse(text: string): string {
|
|
100
|
+
let out = text;
|
|
101
|
+
out = out.replace(DETAILS_BLOCK, (_m, summary: string | undefined, body: string) =>
|
|
102
|
+
toExpandableBlockquote(summary ?? "", body ?? ""),
|
|
103
|
+
);
|
|
104
|
+
out = out.replace(ORPHAN_TAGS, "");
|
|
105
|
+
out = out.replace(CARET_PAIR, (_m, inner: string) => inner);
|
|
106
|
+
out = out.replace(FOOTNOTE_MARKER, "");
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Neutralise Telegram-unrenderable tokens (`<details>`, `^…^`, `[^1]`) on the
|
|
112
|
+
* FINAL rendered rich-markdown string. Code / links / tables are verbatim.
|
|
113
|
+
* Deterministic, idempotent, and a strict no-op absent any target token.
|
|
114
|
+
*/
|
|
115
|
+
export function guardUnsupportedTokens(text: string): string {
|
|
116
|
+
// Cheap pre-check: nothing to do unless a target trigger char is present.
|
|
117
|
+
if (!/[\^]|<details|<\/details|<summary/i.test(text)) return text;
|
|
118
|
+
return splitProtectedSegments(text)
|
|
119
|
+
.map((seg) => (seg.code ? seg.text : repairProse(seg.text)))
|
|
120
|
+
.join("");
|
|
121
|
+
}
|
|
@@ -22,6 +22,7 @@ import { guardDollarMath } from './render/dollar-math-guard.js'
|
|
|
22
22
|
import { guardAccidentalEmphasis } from './render/emphasis-guard.js'
|
|
23
23
|
import { guardAccidentalBlockConstructs, guardAccidentalHeading } from './render/line-start-guard.js'
|
|
24
24
|
import { guardAccidentalInlinePairs } from './render/inline-pairs-guard.js'
|
|
25
|
+
import { guardUnsupportedTokens } from './render/unsupported-token-guard.js'
|
|
25
26
|
|
|
26
27
|
/** The `InputRichMessage` shape grammy 1.44 accepts on send AND edit. */
|
|
27
28
|
export interface InputRichMessageMarkdown {
|
|
@@ -56,6 +57,14 @@ export interface InputRichMessageMarkdown {
|
|
|
56
57
|
*/
|
|
57
58
|
export function guardAccidentalFormatting(markdown: string): string {
|
|
58
59
|
let out = markdown
|
|
60
|
+
// Repair Telegram-unrenderable tokens FIRST: `<details>` folds into a `**> `
|
|
61
|
+
// expandable blockquote whose `> `-with-space lines are invisible to the
|
|
62
|
+
// block-construct guard (which only escapes space-LESS `>digit`/`>=`), and
|
|
63
|
+
// `**> ` is the exact marker switchroom's own render path emits — so the
|
|
64
|
+
// downstream emphasis/block guards treat it identically to a native
|
|
65
|
+
// expandable quote. Caret/footnote removal inserts no trigger char for any
|
|
66
|
+
// sibling guard, so this pass neither creates nor destroys their signals.
|
|
67
|
+
out = guardUnsupportedTokens(out)
|
|
59
68
|
out = guardAccidentalEmphasis(out)
|
|
60
69
|
out = guardAccidentalHeading(out)
|
|
61
70
|
out = guardAccidentalBlockConstructs(out)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { guardUnsupportedTokens } from "../../render/unsupported-token-guard.js";
|
|
3
|
+
import { richMessage } from "../../rich-send.js";
|
|
4
|
+
|
|
5
|
+
describe("guardUnsupportedTokens — deterministic send-time repair", () => {
|
|
6
|
+
it("folds <details><summary> into a Telegram expandable blockquote", () => {
|
|
7
|
+
const input =
|
|
8
|
+
"Here is the trace:\n<details><summary>Stack trace</summary>\nline 1\nline 2\n</details>\ndone";
|
|
9
|
+
const out = guardUnsupportedTokens(input);
|
|
10
|
+
// Anti-tautology: the raw HTML tags MUST be gone from the wire body.
|
|
11
|
+
expect(out).not.toContain("<details>");
|
|
12
|
+
expect(out).not.toContain("</details>");
|
|
13
|
+
expect(out).not.toContain("<summary>");
|
|
14
|
+
// Summary becomes the expandable-blockquote first line (`**> ` marker).
|
|
15
|
+
expect(out).toContain("**> Stack trace");
|
|
16
|
+
// Body lines become plain `> ` continuation lines.
|
|
17
|
+
expect(out).toContain("> line 1");
|
|
18
|
+
expect(out).toContain("> line 2");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("folds a <details> without a <summary> into an expandable blockquote", () => {
|
|
22
|
+
const out = guardUnsupportedTokens("<details>hidden body text</details>");
|
|
23
|
+
expect(out).not.toContain("<details");
|
|
24
|
+
expect(out).toContain("**> hidden body text");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("strips caret highlight / superscript pairs to their inner text", () => {
|
|
28
|
+
expect(guardUnsupportedTokens("energy is x^2^ joules")).toBe(
|
|
29
|
+
"energy is x2 joules",
|
|
30
|
+
);
|
|
31
|
+
expect(guardUnsupportedTokens("a ^highlighted^ word")).toBe(
|
|
32
|
+
"a highlighted word",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("removes footnote reference markers but keeps definition lines", () => {
|
|
37
|
+
expect(guardUnsupportedTokens("see the note[^1] here")).toBe(
|
|
38
|
+
"see the note here",
|
|
39
|
+
);
|
|
40
|
+
// A `[^1]:` definition line is left intact (the negative lookahead).
|
|
41
|
+
expect(guardUnsupportedTokens("[^1]: the definition")).toBe(
|
|
42
|
+
"[^1]: the definition",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("leaves unpaired carets scattered across prose intact (no interior space)", () => {
|
|
47
|
+
// Two separate literal carets across words are NOT a highlight pair —
|
|
48
|
+
// stripping both and joining the words would corrupt the prose.
|
|
49
|
+
expect(guardUnsupportedTokens("the exponent a^n plus b^m here")).toBe(
|
|
50
|
+
"the exponent a^n plus b^m here",
|
|
51
|
+
);
|
|
52
|
+
expect(guardUnsupportedTokens("score^total and rank^final done")).toBe(
|
|
53
|
+
"score^total and rank^final done",
|
|
54
|
+
);
|
|
55
|
+
// But a genuine adjacent highlight/superscript is still repaired.
|
|
56
|
+
expect(guardUnsupportedTokens("value ^highlight^ here")).toBe(
|
|
57
|
+
"value highlight here",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("leaves whitespace-free multi-caret math expressions intact (no false superscript pairing)", () => {
|
|
62
|
+
// Review MED-LOW: the caret pair must NOT span two independent exponents.
|
|
63
|
+
// A permissive inner run would pair `^2+b^` in `a^2+b^2=c^2` and strip the
|
|
64
|
+
// carets, mangling the math. The alphanumeric-only inner run breaks the run
|
|
65
|
+
// at `+`/`=`/`-`, so each is left as a literal caret expression.
|
|
66
|
+
expect(guardUnsupportedTokens("a^2+b^2=c^2")).toBe("a^2+b^2=c^2");
|
|
67
|
+
expect(guardUnsupportedTokens("2^8")).toBe("2^8");
|
|
68
|
+
expect(guardUnsupportedTokens("x^n")).toBe("x^n");
|
|
69
|
+
expect(guardUnsupportedTokens("compute a^2-b^2 now")).toBe("compute a^2-b^2 now");
|
|
70
|
+
// And a real single superscript token is still repaired.
|
|
71
|
+
expect(guardUnsupportedTokens("x^2^ metres")).toBe("x2 metres");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("leaves in-prose bracket literals intact, repairs real numeric footnotes", () => {
|
|
75
|
+
// `array[^index]` is a negated-char-class / index literal, not a footnote.
|
|
76
|
+
expect(guardUnsupportedTokens("array[^index] lookup")).toBe(
|
|
77
|
+
"array[^index] lookup",
|
|
78
|
+
);
|
|
79
|
+
expect(guardUnsupportedTokens("use [^/] to match")).toBe(
|
|
80
|
+
"use [^/] to match",
|
|
81
|
+
);
|
|
82
|
+
// A real numeric footnote marker is still stripped.
|
|
83
|
+
expect(guardUnsupportedTokens("see the note[^1] here")).toBe(
|
|
84
|
+
"see the note here",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("is a strict no-op for clean markdown (no target tokens)", () => {
|
|
89
|
+
const clean =
|
|
90
|
+
"**Answer:** the `config.yaml` file. See [docs](https://example.com/x).";
|
|
91
|
+
expect(guardUnsupportedTokens(clean)).toBe(clean);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("never touches carets inside code spans / fenced blocks", () => {
|
|
95
|
+
const code = "run `git rev-parse HEAD^` then\n```bash\necho x^2^\n```";
|
|
96
|
+
// Carets inside the code span and fence survive verbatim.
|
|
97
|
+
expect(guardUnsupportedTokens(code)).toBe(code);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("leaves `$` untouched (currency is owned by guardDollarMath)", () => {
|
|
101
|
+
const money = "it costs $5 and $10";
|
|
102
|
+
expect(guardUnsupportedTokens(money)).toBe(money);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("is idempotent — a second pass changes nothing", () => {
|
|
106
|
+
const input = "<details><summary>T</summary>b</details> and x^2^ and n[^3]";
|
|
107
|
+
const once = guardUnsupportedTokens(input);
|
|
108
|
+
expect(guardUnsupportedTokens(once)).toBe(once);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("OUTCOME: the composed richMessage wire body has unsupported tokens repaired", () => {
|
|
112
|
+
// End-to-end through the real send-path composition. This is the
|
|
113
|
+
// anti-tautology anchor: without guardUnsupportedTokens wired into
|
|
114
|
+
// guardAccidentalFormatting, the raw `<details>` / `^` / `[^1]` tokens would
|
|
115
|
+
// reach the wire and this assertion would FAIL.
|
|
116
|
+
const { markdown } = richMessage(
|
|
117
|
+
"note[^1]\n<details><summary>More</summary>\ndetail line\n</details>\nx^2^",
|
|
118
|
+
);
|
|
119
|
+
expect(markdown).not.toContain("<details>");
|
|
120
|
+
expect(markdown).not.toContain("[^1]");
|
|
121
|
+
expect(markdown).toContain("**> More");
|
|
122
|
+
expect(markdown).toContain("> detail line");
|
|
123
|
+
expect(markdown).toContain("x2");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: telegram-formatting
|
|
3
|
-
description: >
|
|
4
|
-
Use when composing a rich or long Telegram reply and you want the full
|
|
5
|
-
formatting palette with exact syntax — expandable blockquotes, spoilers,
|
|
6
|
-
highlight, code-fence language hints, GFM tables, nested lists — plus the
|
|
7
|
-
escaping rules and the framework's send-time chunking/normalizer behaviour.
|
|
8
|
-
Load it when a message genuinely needs structure, NOT for everyday short
|
|
9
|
-
replies (plain prose already wins there). Teaches judgment first: which
|
|
10
|
-
construct helps the reader vs when plain text is better. Do NOT use for
|
|
11
|
-
deciding whether to reply, or for non-Telegram output.
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
# Telegram formatting — the full palette
|
|
15
|
-
|
|
16
|
-
Every outbound Switchroom message renders as raw GFM markdown over Telegram
|
|
17
|
-
Bot API 10.1 rich messages (`telegram-plugin/rich-send.ts` `richMessage(md)` →
|
|
18
|
-
`{ markdown }` → `sendRichMessage` / `editMessageText({ markdown })`). No HTML,
|
|
19
|
-
no `parse_mode`. This skill is the depth reference behind the boot-injected
|
|
20
|
-
floor card: the full construct vocabulary, correct syntax, escaping, and the
|
|
21
|
-
send-time behaviour you can rely on.
|
|
22
|
-
|
|
23
|
-
## Judgment first — reach for structure only when it helps the reader
|
|
24
|
-
|
|
25
|
-
The floor card's stance is the law here too: **structure exists for the reader,
|
|
26
|
-
not the writer.** Loading this skill does not mean "use everything below." Match
|
|
27
|
-
the construct to the message.
|
|
28
|
-
|
|
29
|
-
- **Short answers (a line or two): plain prose, no formatting.** "on it,
|
|
30
|
-
pulling the logs now" is already perfect. No bold, no bullets, no headings.
|
|
31
|
-
Most replies live here — don't dress them up.
|
|
32
|
-
- **Default: light structure.** Bold ONLY the one key fact or answer, never
|
|
33
|
-
more. A list only for 3+ genuinely parallel items the reader will scan or
|
|
34
|
-
compare; two items or a flowing thought stay prose. `code spans` for
|
|
35
|
-
identifiers (filenames, commands, config keys, error codes) — tap-to-copy.
|
|
36
|
-
- **Long / multi-section answers may add the rich constructs below** — tables,
|
|
37
|
-
headings, blockquotes, expandable blocks, fences — but only when they cut the
|
|
38
|
-
reader's effort. If the structure doesn't reduce scanning effort, drop it. A
|
|
39
|
-
two-item bullet list is worse than a sentence; a heading on a three-line reply
|
|
40
|
-
is noise. When in doubt, shorter and plainer wins.
|
|
41
|
-
|
|
42
|
-
Over-bolded messages (most of the text bold, whole paragraphs/lists bolded) get
|
|
43
|
-
their bold stripped at send time — so bold sparingly and deliberately.
|
|
44
|
-
|
|
45
|
-
## Full rich vocabulary
|
|
46
|
-
|
|
47
|
-
### Inline spans
|
|
48
|
-
|
|
49
|
-
| Effect | Markdown | When / notes |
|
|
50
|
-
| --- | --- | --- |
|
|
51
|
-
| Bold | `**text**` | The one key fact or answer, not decoration. |
|
|
52
|
-
| Italic | `*text*` or `_text_` | Light emphasis, labels, asides. |
|
|
53
|
-
| Strikethrough | `~~text~~` | Retractions, "was X now Y". |
|
|
54
|
-
| Spoiler | `\|\|text\|\|` | Only for an opt-in surprise or a reveal the reader chose to wait for (a punchline they want suspended) — NEVER to hide an answer someone is asking for or anxious about; when in doubt, show it plainly. Surfaces as a `spoiler` entity on the wire (live-verified 2026-07). |
|
|
55
|
-
| Highlight / marked | `==text==` | Surfaces as a `marked` entity on the wire (live-verified 2026-07). `=` is an `escapeMarkdown` special, so dynamic text won't trigger it by accident. |
|
|
56
|
-
| Inline code | `` `text` `` | Identifiers, tap-to-copy. Content is literal — no escaping inside. |
|
|
57
|
-
| Link | `[label](https://…)` | Standard GFM link. |
|
|
58
|
-
|
|
59
|
-
**Do NOT rely on these — they don't render as intended:**
|
|
60
|
-
|
|
61
|
-
- **Underline** — there is NO underline token on this path. `__text__` renders
|
|
62
|
-
as **bold** (Telegram's rich-message markdown parser reads a `__…__` run
|
|
63
|
-
identically to `**…**`, live-verified against the Bot API 2026-07). Use `**`
|
|
64
|
-
for bold and don't reach for underline.
|
|
65
|
-
- **Subscript** `~text~` (single tilde) and **superscript** `^text^` fall back
|
|
66
|
-
to literal text in rich messages — avoid (write "squared", not `x^2^`).
|
|
67
|
-
- **Custom emoji** (premium custom-emoji entity) renders as a normal emoji for
|
|
68
|
-
non-premium viewers — don't rely on it to carry meaning.
|
|
69
|
-
|
|
70
|
-
(Inline math `$…$`, HTML `<details>`/collapsible, and footnotes `[^1]` are NOT
|
|
71
|
-
supported on this path — do not emit them; they degrade to literal or neutralised
|
|
72
|
-
text.)
|
|
73
|
-
|
|
74
|
-
### Block types
|
|
75
|
-
|
|
76
|
-
- **Code fence** — ` ```lang ` … ` ``` `. Multi-line literal output (diffs,
|
|
77
|
-
logs, JSON, command blocks). The language hint (`diff`, `json`, `bash`, …)
|
|
78
|
-
sharpens syntax rendering — use it. Content inside is verbatim, never escape
|
|
79
|
-
it; the only hazard is an embedded ` ``` ` closing the block early, which the
|
|
80
|
-
framework defuses (`preBlock` in `shared/bot-runtime.ts`).
|
|
81
|
-
- **Preformatted block** — a code fence with NO language, for fixed-width
|
|
82
|
-
non-code (ASCII tables, aligned columns).
|
|
83
|
-
- **Bulleted list** — `- item` (also `*` / `+`). 3+ parallel items only.
|
|
84
|
-
- **Numbered list** — `1. item`. Ordered steps or ranked items.
|
|
85
|
-
- **Nested lists** — indent sub-items; tight (no blank lines) vs loose (blank
|
|
86
|
-
lines between items) both render. 3-level nesting is live-verified.
|
|
87
|
-
- **Task list** — `- [ ] todo` / `- [x] done`.
|
|
88
|
-
- **Table** — GFM pipe table (`| col | col |` + `| --- | --- |` separator),
|
|
89
|
-
optional per-column alignment (`:---`, `:---:`, `---:`). 2-D data ONLY (rows ×
|
|
90
|
-
columns) — not a substitute for prose. Chunk-safe: `splitMarkdownChunks` never
|
|
91
|
-
bisects a row.
|
|
92
|
-
- **Blockquote** — `> quoted`. Quoted text or an indented continuation; the
|
|
93
|
-
right way to indent, because Telegram drops leading whitespace.
|
|
94
|
-
- **Expandable blockquote** — `**> …` (Bot API 10.1). A long quote/aside the
|
|
95
|
-
reader can collapse and expand. The flagship rich construct — use it for a
|
|
96
|
-
long quotation, a stack trace, or a detailed aside you don't want dominating
|
|
97
|
-
the message. First line carries the `**> ` marker; continuation lines use `> `.
|
|
98
|
-
- **Section heading** — `#` … `######`. Only in a genuinely long, multi-section
|
|
99
|
-
answer. Never on a short reply.
|
|
100
|
-
- **Divider** — `---` (thematic break). Heavy horizontal rule between genuinely
|
|
101
|
-
separate sections. Use sparingly.
|
|
102
|
-
- **Collage / album** — multiple images grouped in one message (media group).
|
|
103
|
-
Send via the attachment path, not markdown.
|
|
104
|
-
|
|
105
|
-
## Escaping rules
|
|
106
|
-
|
|
107
|
-
Dynamic content (filenames, ids, arbitrary user text) interpolated into a
|
|
108
|
-
hand-built markdown card MUST be escaped so it renders LITERALLY instead of
|
|
109
|
-
being parsed as formatting. Use `escapeMarkdown(value)` from
|
|
110
|
-
`telegram-plugin/format.ts`.
|
|
111
|
-
|
|
112
|
-
`escapeMarkdown` escapes exactly the characters that trigger INLINE formatting:
|
|
113
|
-
backslash, `` ` ``, `*`, `_`, `~`, `=`, `[`, `]`, `|` — the set `` \`*_~=[]| ``.
|
|
114
|
-
The backslash is escaped first so it never double-escapes a following special.
|
|
115
|
-
It deliberately does **not** escape `.` `-` `+` `#` `(` `)` `{` `}` `!` `>`:
|
|
116
|
-
those are only meaningful at line-start or in link/structure context, and
|
|
117
|
-
escaping them mid-word would litter filenames (`foo.ts`), versions (`v1.2-rc`),
|
|
118
|
-
and URLs with visible backslashes.
|
|
119
|
-
|
|
120
|
-
- **Bold/italic a dynamic value:** `` `**${escapeMarkdown(value)}**` ``.
|
|
121
|
-
- **Code-span a dynamic value:** `` `\`${value}\`` `` — code spans need NO
|
|
122
|
-
escaping (backtick content is already literal). This is the preferred, safest
|
|
123
|
-
way to render any identifier.
|
|
124
|
-
|
|
125
|
-
## Send-time behaviour you can rely on
|
|
126
|
-
|
|
127
|
-
- **Chunking.** Hard cap is `RICH_MESSAGE_MAX_CHARS = 32768` (32768 accepted,
|
|
128
|
-
32769 rejected — the single constant, never re-derive it). A longer body is
|
|
129
|
-
split by `splitMarkdownChunks(text, 32768)` in `format.ts`: it cuts at the
|
|
130
|
-
largest safe boundary (blank line → newline → space), **never bisects a fenced
|
|
131
|
-
code block** (`backOffOpenFence`) and **never bisects a table row**
|
|
132
|
-
(`backOffTableRow`). A single indivisible region larger than the cap is
|
|
133
|
-
emitted whole and re-split / hard-sliced at send time rather than hanging.
|
|
134
|
-
Long before 32768, ask whether a wall of text is the right answer at all.
|
|
135
|
-
- **Typography normalizer (deterministic, every message).** Block spacing (one
|
|
136
|
-
blank line between distinct blocks), em/en dashes, and `•` bullet markers are
|
|
137
|
-
rewritten at send time. A LONE `\n` between two prose paragraphs is promoted
|
|
138
|
-
to a real visual break; runs of 3+ newlines collapse to `\n\n`; lists, tables,
|
|
139
|
-
code, and existing `\n\n` gaps are left exactly as written. Don't hand-tune
|
|
140
|
-
spacing or fight the normalizer — write the content, the gateway makes the
|
|
141
|
-
typography consistent.
|
|
142
|
-
|
|
143
|
-
## The one rule that outranks everything here
|
|
144
|
-
|
|
145
|
-
You loaded this skill to format a rich message well — but the best formatting is
|
|
146
|
-
still the least that serves the reader. Use the palette to make a genuinely
|
|
147
|
-
complex answer scannable, never to decorate a simple one.
|