switchroom 0.19.36 → 0.19.37

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.36", COMMIT_SHA = "ba505b15";
2123
+ var VERSION = "0.19.37", COMMIT_SHA = "5d2f9a17";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -31355,8 +31355,8 @@ into your system prompt every session; this is the conversational summary.
31355
31355
  Kicking off a worker to crawl the changelog.
31356
31356
 
31357
31357
  **What it'll do**
31358
- \u2022 pull every entry since v0.14
31359
- \u2022 flag anything user-facing
31358
+ - pull every entry since v0.14
31359
+ - flag anything user-facing
31360
31360
 
31361
31361
  **Back in** ~2 min with a synthesized summary.
31362
31362
  - Use backtick-wrapped \`inline code\` for filenames, commands, and
@@ -31540,7 +31540,8 @@ You're writing for a phone screen in Telegram. Every reply renders as rich Markd
31540
31540
  fenced code blocks ALWAYS with a language
31541
31541
  hint (\`\`\`diff, \`\`\`json, \`\`\`bash \u2014 bare fence only for non-code fixed-width output);
31542
31542
  and the flagship \u2014 the **expandable blockquote** \`**> first line\` + \`> continuation\`
31543
- for a long quote, stack trace, or detailed aside the reader can collapse. Use it
31543
+ for a long quote, stack trace, or detailed aside the reader can collapse. The \`**>\`
31544
+ opener must be at the line start (column 0) \u2014 never indented \u2014 or it won't parse. Use it
31544
31545
  whenever a bulky supporting block would otherwise dominate the message.
31545
31546
 
31546
31547
  Renders wrong on this path \u2014 never emit: underline (\`__x__\` renders as bold),
@@ -31623,6 +31624,8 @@ var init_scaffold = __esm(() => {
31623
31624
  "mcp__hindsight__list_banks",
31624
31625
  "mcp__hindsight__get_memory",
31625
31626
  "mcp__hindsight__list_memories",
31627
+ "mcp__hindsight__invalidate_memory",
31628
+ "mcp__hindsight__update_memory",
31626
31629
  "mcp__hindsight__get_document",
31627
31630
  "mcp__hindsight__list_documents",
31628
31631
  "mcp__hindsight__delete_document",
@@ -21311,7 +21311,7 @@ function allocateAgentUid(name) {
21311
21311
  }
21312
21312
 
21313
21313
  // src/build-info.ts
21314
- var VERSION = "0.19.36";
21314
+ var VERSION = "0.19.37";
21315
21315
 
21316
21316
  // src/setup/hindsight-recall-tunables.ts
21317
21317
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
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.36",
4
+ "version": "0.19.37",
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": {
@@ -94,7 +94,7 @@ const TOOL_SCHEMAS = [
94
94
  // client and this detail was being silently truncated away.
95
95
  'FORUM TOPICS: a reply is auto-routed back to the topic the question came from, so do NOT pass message_thread_id on a normal reply — pass the inbound\'s origin_turn_id instead, so the answer lands in the right topic even if a message from another topic arrived while you were working. message_thread_id is only for deliberately posting into a topic that is not the one you were asked in. ' +
96
96
  // Format modes: likewise moved out of the truncated instructions string.
97
- 'FORMAT: the default format is "html" write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Pass format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text sent verbatim.',
97
+ 'FORMAT: by default your text is rendered as rich GFM markdown (Bot API 10.1) write natural markdown (bold, italic, code, links, code blocks, lists, tables) and it renders as-is. Pass format: "text" to send plain text verbatim with no rendering. "html" and "markdownv2" are accepted legacy aliases that route through the exact same single rich GFM path — there is no separate HTML engine and no auto-escaping.',
98
98
  inputSchema: {
99
99
  type: 'object',
100
100
  properties: {
@@ -105,7 +105,7 @@ const TOOL_SCHEMAS = [
105
105
  message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified.' },
106
106
  origin_turn_id: { type: 'string', description: 'In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message\'s topic even if another topic\'s turn started meanwhile. Omit in DMs / single-topic chats.' },
107
107
  files: { type: 'array', items: { type: 'string' }, description: 'Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each. Telegram rejects photos with extreme dimensions (aspect ratio over ~10:1, width+height over 10000px, or over 10MB) — very tall images like full-page screenshots are auto-rerouted as documents; crop or split them first if the user should see them inline as photos.' },
108
- format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
108
+ format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path — no separate HTML engine, no auto-escaping." },
109
109
  disable_web_page_preview: { type: 'boolean', description: 'Disable link preview thumbnails. Default: true.' },
110
110
  protect_content: { type: 'boolean', description: 'When true, Telegram prevents the message from being forwarded or saved.' },
111
111
  quote_text: { type: 'string', description: 'Surgical quote: specific text to highlight from the reply_to message. Requires reply_to.' },
@@ -180,7 +180,7 @@ const TOOL_SCHEMAS = [
180
180
  chat_id: { type: 'string' },
181
181
  message_id: { type: 'string' },
182
182
  text: { type: 'string' },
183
- format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
183
+ format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path — no separate HTML engine, no auto-escaping." },
184
184
  },
185
185
  required: ['chat_id', 'message_id', 'text'],
186
186
  },
@@ -341,7 +341,7 @@ const TOOL_SCHEMAS = [
341
341
  type: 'object',
342
342
  properties: {
343
343
  chat_id: { type: 'string', description: 'Chat that should receive the question. Pass from inbound meta.' },
344
- question: { type: 'string', description: 'The question text. Plain text or HTML. Keep it short — buttons render below.' },
344
+ question: { type: 'string', description: 'The question text. Plain text or GFM markdown (rendered through the same rich path as replies). Keep it short — buttons render below.' },
345
345
  options: {
346
346
  type: 'array',
347
347
  items: { type: 'string' },
@@ -25061,7 +25061,7 @@ var mcp = new Server({ name: "telegram", version: "1.0.0" }, {
25061
25061
  var TOOL_SCHEMAS = [
25062
25062
  {
25063
25063
  name: "reply",
25064
- description: "Reply on Telegram. Pass chat_id from the inbound message. By default the reply is a quote-reply to the latest inbound user message in this chat+thread \u2014 pass quote:false to opt out, or pass an explicit reply_to to thread under a specific earlier message. files (absolute paths) attach images or documents. inline_keyboard adds tappable buttons (URL or callback) under the message \u2014 single-tap actions beat asking the user to type YES. " + "FORUM TOPICS: a reply is auto-routed back to the topic the question came from, so do NOT pass message_thread_id on a normal reply \u2014 pass the inbound's origin_turn_id instead, so the answer lands in the right topic even if a message from another topic arrived while you were working. message_thread_id is only for deliberately posting into a topic that is not the one you were asked in. " + 'FORMAT: the default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Pass format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text sent verbatim.',
25064
+ description: "Reply on Telegram. Pass chat_id from the inbound message. By default the reply is a quote-reply to the latest inbound user message in this chat+thread \u2014 pass quote:false to opt out, or pass an explicit reply_to to thread under a specific earlier message. files (absolute paths) attach images or documents. inline_keyboard adds tappable buttons (URL or callback) under the message \u2014 single-tap actions beat asking the user to type YES. " + "FORUM TOPICS: a reply is auto-routed back to the topic the question came from, so do NOT pass message_thread_id on a normal reply \u2014 pass the inbound's origin_turn_id instead, so the answer lands in the right topic even if a message from another topic arrived while you were working. message_thread_id is only for deliberately posting into a topic that is not the one you were asked in. " + 'FORMAT: by default your text is rendered as rich GFM markdown (Bot API 10.1) \u2014 write natural markdown (bold, italic, code, links, code blocks, lists, tables) and it renders as-is. Pass format: "text" to send plain text verbatim with no rendering. "html" and "markdownv2" are accepted legacy aliases that route through the exact same single rich GFM path \u2014 there is no separate HTML engine and no auto-escaping.',
25065
25065
  inputSchema: {
25066
25066
  type: "object",
25067
25067
  properties: {
@@ -25072,7 +25072,7 @@ var TOOL_SCHEMAS = [
25072
25072
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." },
25073
25073
  origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
25074
25074
  files: { type: "array", items: { type: "string" }, description: "Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each. Telegram rejects photos with extreme dimensions (aspect ratio over ~10:1, width+height over 10000px, or over 10MB) \u2014 very tall images like full-page screenshots are auto-rerouted as documents; crop or split them first if the user should see them inline as photos." },
25075
- format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
25075
+ format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path \u2014 no separate HTML engine, no auto-escaping." },
25076
25076
  disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
25077
25077
  protect_content: { type: "boolean", description: "When true, Telegram prevents the message from being forwarded or saved." },
25078
25078
  quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Requires reply_to." },
@@ -25146,7 +25146,7 @@ var TOOL_SCHEMAS = [
25146
25146
  chat_id: { type: "string" },
25147
25147
  message_id: { type: "string" },
25148
25148
  text: { type: "string" },
25149
- format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." }
25149
+ format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path \u2014 no separate HTML engine, no auto-escaping." }
25150
25150
  },
25151
25151
  required: ["chat_id", "message_id", "text"]
25152
25152
  }
@@ -25303,7 +25303,7 @@ var TOOL_SCHEMAS = [
25303
25303
  type: "object",
25304
25304
  properties: {
25305
25305
  chat_id: { type: "string", description: "Chat that should receive the question. Pass from inbound meta." },
25306
- question: { type: "string", description: "The question text. Plain text or HTML. Keep it short \u2014 buttons render below." },
25306
+ question: { type: "string", description: "The question text. Plain text or GFM markdown (rendered through the same rich path as replies). Keep it short \u2014 buttons render below." },
25307
25307
  options: {
25308
25308
  type: "array",
25309
25309
  items: { type: "string" },
@@ -6923,7 +6923,11 @@ function normalizePunctuation(text) {
6923
6923
  const escNonce = nonce.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6924
6924
  const linkRestoreRe = new RegExp(`\x00RML${escNonce}_(\\d+)\x00`, "g");
6925
6925
  const restoreLinks = (s) => s.replace(linkRestoreRe, (_m, idx) => linkMasks[Number(idx)] ?? _m);
6926
- let out = maskedAutolinks.replace(/(\S)[ \t][\u2014\u2013][ \t](?=(\S))/g, (_m, a, b) => /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `).replace(/(\w)\u2014(?=(\w))/g, (_m, a, b) => /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `).replace(/(\w)\u2013(?=\w)/g, "$1-");
6926
+ const rewriteDashes = (line) => line.replace(/(\S)[ \t][\u2014\u2013][ \t](?=(\S))/g, (_m, a, b) => /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `).replace(/(\w)\u2014(?=(\w))/g, (_m, a, b) => /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `).replace(/(\w)\u2013(?=\w)/g, "$1-");
6927
+ const isQuotedLine = (line) => isBlockquoteLine(line) || line.trimStart().startsWith("**>");
6928
+ let out = maskedAutolinks.split(`
6929
+ `).map((line) => isQuotedLine(line) ? line : rewriteDashes(line)).join(`
6930
+ `);
6927
6931
  out = restoreLinks(out);
6928
6932
  out = out.split(`
6929
6933
  `).map((line) => line.replace(/^([ \t]*)[\u2022\u00b7][ \t]+/, "$1- ")).join(`
@@ -13607,7 +13611,7 @@ function guardUnsupportedTokens(text) {
13607
13611
  var CARET_PAIR, FOOTNOTE_MARKER, DETAILS_BLOCK, ORPHAN_TAGS;
13608
13612
  var init_unsupported_token_guard = __esm(() => {
13609
13613
  CARET_PAIR = /\^([A-Za-z0-9]+)\^/g;
13610
- FOOTNOTE_MARKER = /\[\^\d+\](?!:)/g;
13614
+ FOOTNOTE_MARKER = /\[\^[A-Za-z0-9]{1,10}\](?!:)/g;
13611
13615
  DETAILS_BLOCK = /<details[^>]*>\s*(?:<summary[^>]*>([\s\S]*?)<\/summary>)?([\s\S]*?)<\/details>/gi;
13612
13616
  ORPHAN_TAGS = /<\/?(?:details|summary)[^>]*>/gi;
13613
13617
  });
@@ -99668,10 +99672,10 @@ function startOutboxSweep(deps) {
99668
99672
  }
99669
99673
 
99670
99674
  // ../src/build-info.ts
99671
- var VERSION2 = "0.19.36";
99672
- var COMMIT_SHA = "ba505b15";
99673
- var COMMIT_DATE = "2026-07-30T07:53:24Z";
99674
- var LATEST_PR = 4019;
99675
+ var VERSION2 = "0.19.37";
99676
+ var COMMIT_SHA = "5d2f9a17";
99677
+ var COMMIT_DATE = "2026-07-30T08:57:38Z";
99678
+ var LATEST_PR = 4023;
99675
99679
  var COMMITS_AHEAD_OF_TAG = 0;
99676
99680
 
99677
99681
  // gateway/boot-version.ts
@@ -24827,7 +24827,7 @@ var init_bridge = __esm(async () => {
24827
24827
  TOOL_SCHEMAS = [
24828
24828
  {
24829
24829
  name: "reply",
24830
- description: "Reply on Telegram. Pass chat_id from the inbound message. By default the reply is a quote-reply to the latest inbound user message in this chat+thread \u2014 pass quote:false to opt out, or pass an explicit reply_to to thread under a specific earlier message. files (absolute paths) attach images or documents. inline_keyboard adds tappable buttons (URL or callback) under the message \u2014 single-tap actions beat asking the user to type YES. " + "FORUM TOPICS: a reply is auto-routed back to the topic the question came from, so do NOT pass message_thread_id on a normal reply \u2014 pass the inbound's origin_turn_id instead, so the answer lands in the right topic even if a message from another topic arrived while you were working. message_thread_id is only for deliberately posting into a topic that is not the one you were asked in. " + 'FORMAT: the default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Pass format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text sent verbatim.',
24830
+ description: "Reply on Telegram. Pass chat_id from the inbound message. By default the reply is a quote-reply to the latest inbound user message in this chat+thread \u2014 pass quote:false to opt out, or pass an explicit reply_to to thread under a specific earlier message. files (absolute paths) attach images or documents. inline_keyboard adds tappable buttons (URL or callback) under the message \u2014 single-tap actions beat asking the user to type YES. " + "FORUM TOPICS: a reply is auto-routed back to the topic the question came from, so do NOT pass message_thread_id on a normal reply \u2014 pass the inbound's origin_turn_id instead, so the answer lands in the right topic even if a message from another topic arrived while you were working. message_thread_id is only for deliberately posting into a topic that is not the one you were asked in. " + 'FORMAT: by default your text is rendered as rich GFM markdown (Bot API 10.1) \u2014 write natural markdown (bold, italic, code, links, code blocks, lists, tables) and it renders as-is. Pass format: "text" to send plain text verbatim with no rendering. "html" and "markdownv2" are accepted legacy aliases that route through the exact same single rich GFM path \u2014 there is no separate HTML engine and no auto-escaping.',
24831
24831
  inputSchema: {
24832
24832
  type: "object",
24833
24833
  properties: {
@@ -24838,7 +24838,7 @@ var init_bridge = __esm(async () => {
24838
24838
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." },
24839
24839
  origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
24840
24840
  files: { type: "array", items: { type: "string" }, description: "Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each. Telegram rejects photos with extreme dimensions (aspect ratio over ~10:1, width+height over 10000px, or over 10MB) \u2014 very tall images like full-page screenshots are auto-rerouted as documents; crop or split them first if the user should see them inline as photos." },
24841
- format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
24841
+ format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path \u2014 no separate HTML engine, no auto-escaping." },
24842
24842
  disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
24843
24843
  protect_content: { type: "boolean", description: "When true, Telegram prevents the message from being forwarded or saved." },
24844
24844
  quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Requires reply_to." },
@@ -24912,7 +24912,7 @@ var init_bridge = __esm(async () => {
24912
24912
  chat_id: { type: "string" },
24913
24913
  message_id: { type: "string" },
24914
24914
  text: { type: "string" },
24915
- format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." }
24915
+ format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path \u2014 no separate HTML engine, no auto-escaping." }
24916
24916
  },
24917
24917
  required: ["chat_id", "message_id", "text"]
24918
24918
  }
@@ -25069,7 +25069,7 @@ var init_bridge = __esm(async () => {
25069
25069
  type: "object",
25070
25070
  properties: {
25071
25071
  chat_id: { type: "string", description: "Chat that should receive the question. Pass from inbound meta." },
25072
- question: { type: "string", description: "The question text. Plain text or HTML. Keep it short \u2014 buttons render below." },
25072
+ question: { type: "string", description: "The question text. Plain text or GFM markdown (rendered through the same rich path as replies). Keep it short \u2014 buttons render below." },
25073
25073
  options: {
25074
25074
  type: "array",
25075
25075
  items: { type: "string" },
@@ -718,21 +718,37 @@ export function normalizePunctuation(text: string): string {
718
718
  const restoreLinks = (s: string): string =>
719
719
  s.replace(linkRestoreRe, (_m, idx: string) => linkMasks[Number(idx)] ?? _m)
720
720
 
721
+ // The dash rewrite runs per line so blockquote lines can be exempted:
722
+ // `>` blockquotes are reserved for VERBATIM quoted text, and rewriting an
723
+ // author's ` — ` to `, ` inside a quotation would corrupt what they quoted.
724
+ // The expandable-blockquote opener `**>` is a blockquote line too even though
725
+ // isBlockquoteLine (which keys on a leading `>`) doesn't catch the `**`
726
+ // prefix, so exempt it explicitly. Code spans and link hrefs stay masked
727
+ // throughout, so their dashes are already protected on every line.
728
+ const rewriteDashes = (line: string): string =>
729
+ line
730
+ // 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
731
+ // flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
732
+ // dashes ("a — b — c") all normalize in one pass — a consumed \S would
733
+ // swallow the char that anchors the next match.
734
+ .replace(/(\S)[ \t][—–][ \t](?=(\S))/g, (_m, a: string, b: string) =>
735
+ /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
736
+ )
737
+ // 2. Bare em-dash between word chars. Numeric range keeps a hyphen.
738
+ // Right flank is a lookahead for the same consecutive-match reason.
739
+ .replace(/(\w)—(?=(\w))/g, (_m, a: string, b: string) =>
740
+ /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
741
+ )
742
+ // 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
743
+ .replace(/(\w)–(?=\w)/g, '$1-')
744
+
745
+ const isQuotedLine = (line: string): boolean =>
746
+ isBlockquoteLine(line) || line.trimStart().startsWith('**>')
747
+
721
748
  let out = maskedAutolinks
722
- // 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
723
- // flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
724
- // dashes ("a — b — c") all normalize in one pass — a consumed \S would
725
- // swallow the char that anchors the next match.
726
- .replace(/(\S)[ \t][—–][ \t](?=(\S))/g, (_m, a: string, b: string) =>
727
- /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
728
- )
729
- // 2. Bare em-dash between word chars. Numeric range keeps a hyphen.
730
- // Right flank is a lookahead for the same consecutive-match reason.
731
- .replace(/(\w)—(?=(\w))/g, (_m, a: string, b: string) =>
732
- /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
733
- )
734
- // 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
735
- .replace(/(\w)–(?=\w)/g, '$1-')
749
+ .split('\n')
750
+ .map((line) => (isQuotedLine(line) ? line : rewriteDashes(line)))
751
+ .join('\n')
736
752
 
737
753
  // Restore link hrefs now that the dash passes are done — before the bullet
738
754
  // pass and the code restore.
@@ -59,12 +59,16 @@ import { splitProtectedSegments } from "./code-segments.js";
59
59
  * `a^2+b^2=c^2`, `2^8`, and `x^n` all pass through untouched. */
60
60
  const CARET_PAIR = /\^([A-Za-z0-9]+)\^/g;
61
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;
62
+ /** Footnote reference marker `[^id]` where the id is a short alphanumeric run
63
+ * (`[^1]`, `[^note]`, `[^ref]`) NOT immediately followed by `:` (which would
64
+ * make it a footnote DEFINITION line we leave intact). Removed entirely.
65
+ * Requiring the id to be 1–10 ALPHANUMERIC chars keeps this off regex-ish
66
+ * literals like `[^/]`, `[^\s]`, `[^-a-z]`, whose bodies contain punctuation
67
+ * and so never match. In-prose subscript-ish `array[^i]` outside a code span
68
+ * is a rare theoretical false positive (an `i` id matches); code spans are
69
+ * masked upstream by splitProtectedSegments so real code is safe, and prose
70
+ * that writes a literal `[^i]` reads as footnote noise anyway. */
71
+ const FOOTNOTE_MARKER = /\[\^[A-Za-z0-9]{1,10}\](?!:)/g;
68
72
 
69
73
  /** `<details>…</details>` with an optional leading `<summary>…</summary>`.
70
74
  * Dot-all via `[\s\S]`; non-greedy so adjacent blocks don't merge. */
@@ -245,6 +245,24 @@ describe('normalizePunctuation', () => {
245
245
  expect(normalizePunctuation('<not—a—url>')).toBe('<not, a, url>')
246
246
  })
247
247
 
248
+ test('does NOT rewrite a dash inside a `>` blockquote (verbatim quote)', () => {
249
+ // `>` blockquotes hold quoted text the author is reproducing verbatim;
250
+ // rewriting their em-dash to `, ` corrupts the quotation.
251
+ expect(normalizePunctuation('> she said — plainly — no')).toBe(
252
+ '> she said — plainly — no',
253
+ )
254
+ // Indented blockquote line is still a blockquote.
255
+ expect(normalizePunctuation(' > quoted — text')).toBe(' > quoted — text')
256
+ // The expandable-blockquote opener `**>` is a blockquote line too.
257
+ expect(normalizePunctuation('**> first — line\n> continues — here')).toBe(
258
+ '**> first — line\n> continues — here',
259
+ )
260
+ // Prose OUTSIDE the quote still normalizes; only the quoted line is spared.
261
+ expect(normalizePunctuation('intro — here\n> quote — verbatim\nafter — end')).toBe(
262
+ 'intro, here\n> quote — verbatim\nafter, end',
263
+ )
264
+ })
265
+
248
266
  test('idempotent', () => {
249
267
  const once = normalizePunctuation('a — b\n• c\nd—e\n1–2')
250
268
  expect(normalizePunctuation(once)).toBe(once)
@@ -162,8 +162,15 @@ describe("moved instruction content landed in tool descriptions", () => {
162
162
 
163
163
  it("format modes landed in the reply description", () => {
164
164
  expect(bridgeCode).toContain("FORMAT:");
165
- expect(bridgeCode).toMatch(/default format is "html"/);
165
+ // The description must describe the ONE real render path (rich GFM), and
166
+ // still name the legacy aliases so the enum values stay documented. It must
167
+ // NOT resurrect the deleted-engine claims (html→HTML conversion, MarkdownV2
168
+ // auto-escaping) — those are false at HEAD (single GFM path, no escaping).
169
+ expect(bridgeCode).toMatch(/rich GFM markdown/);
166
170
  expect(bridgeCode).toContain("markdownv2");
171
+ expect(bridgeCode).not.toMatch(/default format is "html"/);
172
+ expect(bridgeCode).not.toMatch(/auto-converted to Telegram HTML/);
173
+ expect(bridgeCode).not.toMatch(/MarkdownV2 with auto-escaping/);
167
174
  });
168
175
 
169
176
  it("history-buffer rationale landed in get_recent_messages", () => {
@@ -71,15 +71,46 @@ describe("guardUnsupportedTokens — deterministic send-time repair", () => {
71
71
  expect(guardUnsupportedTokens("x^2^ metres")).toBe("x2 metres");
72
72
  });
73
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",
74
+ it("repairs alphanumeric footnote markers, not just numeric", () => {
75
+ // POLISH-4: widened from digits-only so `[^note]`/`[^ref]` no longer ship
76
+ // as literal bracket noise. Each would survive UNTOUCHED on origin/main.
77
+ expect(guardUnsupportedTokens("see the note[^note] here")).toBe(
78
+ "see the note here",
79
+ );
80
+ expect(guardUnsupportedTokens("as shown[^ref] above")).toBe(
81
+ "as shown above",
82
+ );
83
+ expect(guardUnsupportedTokens("point[^fn1] made")).toBe("point made");
84
+ // A single-letter id is a footnote too.
85
+ expect(guardUnsupportedTokens("claim[^a] holds")).toBe("claim holds");
86
+ // Definition lines with an alphanumeric id are still left intact (`(?!:)`).
87
+ expect(guardUnsupportedTokens("[^note]: the definition")).toBe(
88
+ "[^note]: the definition",
78
89
  );
90
+ });
91
+
92
+ it("leaves regex-literal negated char classes intact", () => {
93
+ // Real negated char classes contain punctuation / ranges / escapes, so the
94
+ // alphanumeric-only id requirement excludes them — these stay verbatim.
79
95
  expect(guardUnsupportedTokens("use [^/] to match")).toBe(
80
96
  "use [^/] to match",
81
97
  );
82
- // A real numeric footnote marker is still stripped.
98
+ expect(guardUnsupportedTokens("strip [^a-z] chars")).toBe(
99
+ "strip [^a-z] chars",
100
+ );
101
+ expect(guardUnsupportedTokens('match [^"] here')).toBe('match [^"] here');
102
+ // Accepted tradeoff (task POLISH-4): a PURE-alphanumeric negated class in
103
+ // BARE prose like `array[^index]` is now treated as a footnote and stripped.
104
+ // This is rare — regex/index literals in real prose live in code spans,
105
+ // which splitProtectedSegments masks (see code-span test below).
106
+ expect(guardUnsupportedTokens("array[^index] lookup")).toBe("array lookup");
107
+ // …but inside a code span the same literal is untouched.
108
+ expect(guardUnsupportedTokens("`array[^index]` lookup")).toBe(
109
+ "`array[^index]` lookup",
110
+ );
111
+ });
112
+
113
+ it("repairs a real numeric footnote marker", () => {
83
114
  expect(guardUnsupportedTokens("see the note[^1] here")).toBe(
84
115
  "see the note here",
85
116
  );