switchroom 0.19.9 → 0.19.11

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.19.9", COMMIT_SHA = "9d791e63";
2123
+ var VERSION = "0.19.11", COMMIT_SHA = "cd60a0c0";
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,45 @@ 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. Use
29479
- a list only for 3+ genuinely parallel items the reader will scan or compare; two
29480
- items or a flowing thought stay prose. \`code spans\` for identifiers: filenames,
29481
- commands, config keys, error codes (tap-to-copy).
29482
- - **Long answers may add tables / headings / blockquotes, but only when they genuinely
29483
- aid scanning**: a table for real 2-D data (rows x columns), headings only in a
29484
- multi-section answer, \`>\` for quoted text. If the structure doesn't cut the
29485
- reader's effort, drop it.
29486
-
29487
- The framework normalizes mechanics in code on every outbound message: block spacing
29488
- (one blank line between distinct blocks), em/en dashes, and \`\u2022\` bullet markers are
29489
- rewritten deterministically at send time. Don't fight it or hand-tune spacing; write
29490
- the content, the gateway makes the typography consistent. Over-bolded messages (most
29491
- of the text bold, or whole paragraphs/lists bolded) have their bold stripped at send
29492
- time, so bold sparingly.
29493
-
29494
- Hard cap is 32768 characters. Long before that, ask whether a wall of text is the
29495
- right answer at all. Structure exists for the reader, not the writer: a two-item
29496
- bullet list is worse than a sentence, a heading on a three-line reply is noise. When
29497
- in doubt, shorter and plainer wins.
29498
-
29499
- Full palette when a rich or long message earns it \u2014 expandable blockquotes, spoilers,
29500
- highlight, code-fence language hints, tables, escaping and chunking rules: load the
29501
- \`telegram-formatting\` skill. Reach for it only when you're actually composing that
29502
- message, never for everyday replies.
29509
+ - **Default: light structure.** Bold ONLY the one key fact or answer, never more.
29510
+ *Italic* for a light aside or a term of art \u2014 rarer than bold; if everything is
29511
+ emphasised, nothing is. ~~Strikethrough~~ only for a genuine retraction or a
29512
+ "was X, now Y" \u2014 never decoration. A
29513
+ list only for 3+ genuinely parallel items the reader will scan or compare; two items
29514
+ or a flowing thought stay prose. A numbered list ONLY when order carries meaning
29515
+ (steps to follow, a ranking); a nested sub-list only for a real hierarchy, one level
29516
+ deep. \`code spans\` for identifiers: filenames, commands,
29517
+ config keys, error codes (tap-to-copy). Wrap dynamic identifiers in backticks:
29518
+ code-span content is literal, so it never needs escaping. Links as \`[label](url)\`,
29519
+ never bare pasted URLs mid-prose.
29520
+ - **Long answers may add the rich constructs, but only when they cut the reader's
29521
+ effort:** a GFM pipe table for real 2-D data (rows x columns); headings only in a
29522
+ multi-section answer; a \`---\` divider only between genuinely separate sections of a
29523
+ long answer (a heading usually does the job alone); \`>\` for quoted text; a spoiler
29524
+ \`||text||\` ONLY when the reader should opt in before seeing it (a punchline, a plot
29525
+ detail, a shock number) \u2014 never for emphasis; \`==highlight==\` to marker-pen the one
29526
+ decisive phrase inside a longer passage \u2014 rarer than bold, never stacked with it;
29527
+ fenced code blocks ALWAYS with a language
29528
+ hint (\`\`\`diff, \`\`\`json, \`\`\`bash \u2014 bare fence only for non-code fixed-width output);
29529
+ and the flagship \u2014 the **expandable blockquote** \`**> first line\` + \`> continuation\`
29530
+ for a long quote, stack trace, or detailed aside the reader can collapse. Use it
29531
+ whenever a bulky supporting block would otherwise dominate the message.
29532
+
29533
+ Renders wrong on this path \u2014 never emit: underline (\`__x__\` renders as bold),
29534
+ \`^sup^\`/\`~sub~\`, \`$math$\`, \`<details>\`, footnotes \`[^1]\`. Write "squared", not \`x^2^\`.
29535
+
29536
+ The framework normalizes mechanics in code on every outbound message: block spacing,
29537
+ em/en dashes, and \`\u2022\` bullet markers are
29538
+ rewritten deterministically; unsupported tokens (the CARET \`^highlight^\` form \u2014
29539
+ \`==highlight==\` renders fine \u2014 plus \`$math$\`, \`<details>\`, footnotes) are repaired;
29540
+ long messages are chunked safely at 32768 chars (fences and
29541
+ table rows never bisected); over-bolded messages get their bold stripped. Don't
29542
+ hand-tune spacing or fight it \u2014 write the content, the gateway makes typography
29543
+ consistent. Long before the cap, ask whether a wall of text is the right answer at all.
29544
+
29545
+ Structure exists for the reader, not the writer: a two-item bullet list is worse than
29546
+ a sentence, a heading on a three-line reply is noise. When in doubt, shorter and
29547
+ plainer wins.
29503
29548
 
29504
29549
  Every turn that answers a user message ends with a user-visible \`reply\`
29505
29550
  \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.9";
26667
+ var VERSION = "0.19.11";
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.9",
4
+ "version": "0.19.11",
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.9";
93573
- var COMMIT_SHA = "9d791e63";
93574
- var COMMIT_DATE = "2026-07-21T06:12:01Z";
93575
- var LATEST_PR = 3492;
93617
+ var VERSION = "0.19.11";
93618
+ var COMMIT_SHA = "cd60a0c0";
93619
+ var COMMIT_DATE = "2026-07-21T21:06:25Z";
93620
+ var LATEST_PR = 3497;
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.