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.
- package/dist/auth-broker/index.js +9 -8
- package/dist/cli/switchroom.js +957 -721
- package/dist/host-control/main.js +17 -15
- package/dist/vault/approvals/kernel-server.js +5 -4
- package/dist/vault/broker/server.js +9 -8
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +57 -11
- 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
|
@@ -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.
|