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.
- package/dist/cli/switchroom.js +7 -4
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +4 -4
- package/telegram-plugin/dist/bridge/bridge.js +4 -4
- package/telegram-plugin/dist/gateway/gateway.js +10 -6
- package/telegram-plugin/dist/server.js +4 -4
- package/telegram-plugin/format.ts +30 -14
- package/telegram-plugin/render/unsupported-token-guard.ts +10 -6
- package/telegram-plugin/tests/format-consistency.test.ts +18 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +8 -1
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +36 -5
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.19.
|
|
2123
|
+
var VERSION = "0.19.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
|
-
|
|
31359
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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'
|
|
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'
|
|
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
|
|
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:
|
|
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'
|
|
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'
|
|
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
|
|
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
|
-
|
|
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 = /\[
|
|
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.
|
|
99672
|
-
var COMMIT_SHA = "
|
|
99673
|
-
var COMMIT_DATE = "2026-07-
|
|
99674
|
-
var LATEST_PR =
|
|
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:
|
|
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'
|
|
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'
|
|
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
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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 `[^
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
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
|
-
|
|
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("
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
);
|