switchroom 0.19.8 → 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.
@@ -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.