switchroom 0.19.36 → 0.19.38
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 +28 -12
- 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 +19 -8
- package/telegram-plugin/dist/server.js +4 -4
- package/telegram-plugin/format.ts +30 -14
- package/telegram-plugin/gateway/gateway.ts +12 -4
- package/telegram-plugin/gateway/handback-preturn-signal.ts +52 -3
- package/telegram-plugin/render/unsupported-token-guard.ts +10 -6
- package/telegram-plugin/tests/format-consistency.test.ts +18 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +67 -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.38", COMMIT_SHA = "9ec88983";
|
|
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",
|
|
@@ -45991,13 +45994,15 @@ var init_install_cron = () => {};
|
|
|
45991
45994
|
function inParts(memories) {
|
|
45992
45995
|
return Math.round(memories * PARTS_PER_MEMORY);
|
|
45993
45996
|
}
|
|
45994
|
-
var RING_MAX = 8, MAX_SAMPLE_AGE_MS, MIN_RETAIN_SAMPLES = 20, RETAIN_FAILURE_RATE = 0.1, PARTS_PER_MEMORY = 6, QUEUE_FLOOR_MEMORIES = 100, QUEUE_FLOOR, QUEUE_GROWTH_FRACTION = 0.1, QUEUE_GROWTH_MIN_ABS_MEMORIES = 20, QUEUE_GROWTH_MIN_ABS, BREACHES_TO_FIRE_LEVEL = 2, BREACHES_TO_FIRE_EDGE = 1, CLEARS_TO_RESOLVE = 2, RENOTIFY_MS, DEFAULT_METRICS_URL = "http://127.0.0.1:18888/metrics", DEFAULT_CONTAINER = "switchroom-hindsight", METRICS_MAX_BYTES, METRICS_TIMEOUT_MS = 1e4, RECALL_OWN_BANK_TIMEOUT_WARN = 0.05, RECALL_OWN_BANK_TIMEOUT_PAGE = 0.15, RECALL_ZERO_MEMORY_WARN = 0.25, RECALL_ZERO_MEMORY_PAGE = 0.4, RECALL_POOL_MEDIAN_WARN = 8, RECALL_POOL_MEDIAN_PAGE = 3, RECALL_SCORE_P50_WARN = 0.02, RECALL_SCORE_P50_PAGE = 0.01, CONSOLIDATION_AGE_WARN_S, CONSOLIDATION_AGE_PAGE_S, CONSOLIDATION_FAILURE_STREAK_WARN = 3, CONSOLIDATION_FAILURE_STREAK_PAGE = 10, CONSOLIDATION_STREAK_RECENCY_S, PENDING_CONSOLIDATION_FLOOR = 500, PENDING_CONSOLIDATION_GROWTH_FRACTION = 0.5, PENDING_CONSOLIDATION_GROWTH_WARN_ABS = 500, PENDING_CONSOLIDATION_GROWTH_PAGE_ABS = 5000, VECTOR_INDEX_SAMPLE_ROWS = 3, RECALL_MIN_SAMPLES = 30, LLM_FAILURE_RATE_WARN = 0.02, LLM_FAILURE_RATE_PAGE = 0.1, LLM_MIN_SAMPLES = 20;
|
|
45997
|
+
var RING_MAX = 8, MAX_SAMPLE_AGE_MS, MIN_RETAIN_SAMPLES = 20, RETAIN_FAILURE_RATE = 0.1, PARTS_PER_MEMORY = 6, QUEUE_FLOOR_MEMORIES = 100, QUEUE_FLOOR, QUEUE_GROWTH_FRACTION = 0.1, QUEUE_GROWTH_MIN_ABS_MEMORIES = 20, QUEUE_GROWTH_MIN_ABS, BREACHES_TO_FIRE_LEVEL = 2, BREACHES_TO_FIRE_EDGE = 1, CLEARS_TO_RESOLVE = 2, RENOTIFY_MS, DEFAULT_METRICS_URL = "http://127.0.0.1:18888/metrics", DEFAULT_CONTAINER = "switchroom-hindsight", METRICS_MAX_BYTES, METRICS_TIMEOUT_MS = 1e4, RECALL_OWN_BANK_TIMEOUT_WARN = 0.05, RECALL_OWN_BANK_TIMEOUT_PAGE = 0.15, RECALL_ZERO_MEMORY_WARN = 0.25, RECALL_ZERO_MEMORY_PAGE = 0.4, RECALL_POOL_MEDIAN_WARN = 8, RECALL_POOL_MEDIAN_PAGE = 3, RECALL_SCORE_P50_WARN = 0.02, RECALL_SCORE_P50_PAGE = 0.01, RECALL_WALL_MS, CONSOLIDATION_AGE_WARN_S, CONSOLIDATION_AGE_PAGE_S, CONSOLIDATION_FAILURE_STREAK_WARN = 3, CONSOLIDATION_FAILURE_STREAK_PAGE = 10, CONSOLIDATION_STREAK_RECENCY_S, PENDING_CONSOLIDATION_FLOOR = 500, PENDING_CONSOLIDATION_GROWTH_FRACTION = 0.5, PENDING_CONSOLIDATION_GROWTH_WARN_ABS = 500, PENDING_CONSOLIDATION_GROWTH_PAGE_ABS = 5000, VECTOR_INDEX_SAMPLE_ROWS = 3, RECALL_MIN_SAMPLES = 30, LLM_FAILURE_RATE_WARN = 0.02, LLM_FAILURE_RATE_PAGE = 0.1, LLM_MIN_SAMPLES = 20;
|
|
45995
45998
|
var init_thresholds = __esm(() => {
|
|
45999
|
+
init_hindsight_recall_tunables();
|
|
45996
46000
|
MAX_SAMPLE_AGE_MS = 3 * 60 * 60000;
|
|
45997
46001
|
QUEUE_FLOOR = inParts(QUEUE_FLOOR_MEMORIES);
|
|
45998
46002
|
QUEUE_GROWTH_MIN_ABS = inParts(QUEUE_GROWTH_MIN_ABS_MEMORIES);
|
|
45999
46003
|
RENOTIFY_MS = 6 * 60 * 60 * 1000;
|
|
46000
46004
|
METRICS_MAX_BYTES = 16 * 1024 * 1024;
|
|
46005
|
+
RECALL_WALL_MS = resolveHindsightRecallTunables(undefined).parallelDeadlineSeconds * 1000;
|
|
46001
46006
|
CONSOLIDATION_AGE_WARN_S = 2 * 60 * 60;
|
|
46002
46007
|
CONSOLIDATION_AGE_PAGE_S = 12 * 60 * 60;
|
|
46003
46008
|
CONSOLIDATION_STREAK_RECENCY_S = 2 * 60 * 60;
|
|
@@ -105309,7 +105314,7 @@ function latencySuffix(r) {
|
|
|
105309
105314
|
return "";
|
|
105310
105315
|
const n = typeof r.elapsedConsidered === "number" ? r.elapsedConsidered : 0;
|
|
105311
105316
|
return `
|
|
105312
|
-
recall wall time p95 ${Math.round(p95)}ms over ${n} fire(s) (not thresholded \u2014 the
|
|
105317
|
+
recall wall time p95 ${Math.round(p95)}ms over ${n} fire(s) (not thresholded \u2014 the ${RECALL_WALL_MS}ms recall deadline leaves no healthy band below it)`;
|
|
105313
105318
|
}
|
|
105314
105319
|
function evaluateRecallOwnBankTimeout(ring) {
|
|
105315
105320
|
const signal = "recall-own-bank-timeout";
|
|
@@ -105908,14 +105913,25 @@ function runInstallCron(cronUser) {
|
|
|
105908
105913
|
`);
|
|
105909
105914
|
return 0;
|
|
105910
105915
|
}
|
|
105911
|
-
|
|
105916
|
+
var PREFERRED_RELAY_AGENT = "overlord";
|
|
105917
|
+
function orderRelayCandidates(candidates, preferred = PREFERRED_RELAY_AGENT) {
|
|
105918
|
+
const first = candidates.filter((c) => c.agent === preferred);
|
|
105919
|
+
const rest = candidates.filter((c) => c.agent !== preferred);
|
|
105920
|
+
return [...first, ...rest];
|
|
105921
|
+
}
|
|
105922
|
+
function collectRelayCandidates(agentsDir) {
|
|
105912
105923
|
const candidates = [];
|
|
105924
|
+
for (const name of readdirSync45(agentsDir).sort()) {
|
|
105925
|
+
const sock = resolve63(agentsDir, name, "telegram", "gateway.sock");
|
|
105926
|
+
if (existsSync112(sock))
|
|
105927
|
+
candidates.push({ agent: name, sock });
|
|
105928
|
+
}
|
|
105929
|
+
return orderRelayCandidates(candidates);
|
|
105930
|
+
}
|
|
105931
|
+
async function notifyOperator(agentsDir, text, log) {
|
|
105932
|
+
let candidates = [];
|
|
105913
105933
|
try {
|
|
105914
|
-
|
|
105915
|
-
const sock = resolve63(agentsDir, name, "telegram", "gateway.sock");
|
|
105916
|
-
if (existsSync112(sock))
|
|
105917
|
-
candidates.push({ agent: name, sock });
|
|
105918
|
-
}
|
|
105934
|
+
candidates = collectRelayCandidates(agentsDir);
|
|
105919
105935
|
} catch (e) {
|
|
105920
105936
|
log(`hindsight-watch: agents dir unreadable (${e.message})`);
|
|
105921
105937
|
}
|
|
@@ -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.38";
|
|
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.38",
|
|
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
|
});
|
|
@@ -53401,6 +53405,12 @@ function createHandbackPreturnSignal(deps) {
|
|
|
53401
53405
|
entry.reapTimer = null;
|
|
53402
53406
|
if (entry.consumed)
|
|
53403
53407
|
return;
|
|
53408
|
+
if (deps.isClaudeBusy?.() === true) {
|
|
53409
|
+
log(`handback-preturn-signal: reap deferred key=${entry.statusKey} ` + `(claude busy \u2014 handback enqueued behind an in-flight turn, not orphaned)
|
|
53410
|
+
`);
|
|
53411
|
+
entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs);
|
|
53412
|
+
return;
|
|
53413
|
+
}
|
|
53404
53414
|
entry.consumed = true;
|
|
53405
53415
|
stopTypingUnlessTurnLive(entry, "orphan reap");
|
|
53406
53416
|
if (entry.activityMessageId != null) {
|
|
@@ -99668,10 +99678,10 @@ function startOutboxSweep(deps) {
|
|
|
99668
99678
|
}
|
|
99669
99679
|
|
|
99670
99680
|
// ../src/build-info.ts
|
|
99671
|
-
var VERSION2 = "0.19.
|
|
99672
|
-
var COMMIT_SHA = "
|
|
99673
|
-
var COMMIT_DATE = "2026-07-
|
|
99674
|
-
var LATEST_PR =
|
|
99681
|
+
var VERSION2 = "0.19.38";
|
|
99682
|
+
var COMMIT_SHA = "9ec88983";
|
|
99683
|
+
var COMMIT_DATE = "2026-07-30T09:50:08Z";
|
|
99684
|
+
var LATEST_PR = 4031;
|
|
99675
99685
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
99676
99686
|
|
|
99677
99687
|
// gateway/boot-version.ts
|
|
@@ -104881,7 +104891,7 @@ async function runMidSessionCardReaper() {
|
|
|
104881
104891
|
const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
|
|
104882
104892
|
path: ACTIVITY_CARD_STORE_PATH,
|
|
104883
104893
|
fs: activityCardStoreFs,
|
|
104884
|
-
isLive: (record2) => topicKeys.has(record2.turnKey),
|
|
104894
|
+
isLive: (record2) => topicKeys.has(record2.turnKey) || handbackPreturnSignal.isPreTurnRecord(record2.turnKey) && isMachineInTurn(),
|
|
104885
104895
|
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
104886
104896
|
now,
|
|
104887
104897
|
finalizeCard: (record2) => {
|
|
@@ -107521,7 +107531,8 @@ var handbackPreturnSignal = createHandbackPreturnSignal({
|
|
|
107521
107531
|
if (live == null || live.endedAt != null)
|
|
107522
107532
|
return false;
|
|
107523
107533
|
return statusKey(live.sessionChatId, live.sessionThreadId) === key;
|
|
107524
|
-
}
|
|
107534
|
+
},
|
|
107535
|
+
isClaudeBusy: () => isMachineInTurn()
|
|
107525
107536
|
});
|
|
107526
107537
|
var memoryLegibilityStager = new MemoryLegibilityStager;
|
|
107527
107538
|
function sendMemoryLegibilityLine(event, chatId, threadId) {
|
|
@@ -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.
|
|
@@ -8909,10 +8909,14 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
8909
8909
|
const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
|
|
8910
8910
|
path: ACTIVITY_CARD_STORE_PATH,
|
|
8911
8911
|
fs: activityCardStoreFs,
|
|
8912
|
-
// A synthetic pre-turn
|
|
8913
|
-
//
|
|
8914
|
-
//
|
|
8915
|
-
|
|
8912
|
+
// A synthetic pre-turn key reads as ownerless here (self-reap is the fast
|
|
8913
|
+
// path; this is the crash backstop). EXCEPTION: a pre-turn handback still
|
|
8914
|
+
// enqueued behind an in-flight turn is NOT an orphan — keep it live while
|
|
8915
|
+
// claude is busy, else this 15-min backstop re-posts the false nudge the
|
|
8916
|
+
// self-reap gate removed. (Boot reaper stays ungated: boot is never mid-turn.)
|
|
8917
|
+
isLive: (record) =>
|
|
8918
|
+
topicKeys.has(record.turnKey) ||
|
|
8919
|
+
(handbackPreturnSignal.isPreTurnRecord(record.turnKey) && isMachineInTurn()),
|
|
8916
8920
|
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
8917
8921
|
now,
|
|
8918
8922
|
finalizeCard: (record) => {
|
|
@@ -13827,6 +13831,10 @@ const handbackPreturnSignal = createHandbackPreturnSignal({
|
|
|
13827
13831
|
// dark-indicator bug). Flag-ON is already per-key; the check is a no-op there.
|
|
13828
13832
|
return statusKey(live.sessionChatId, live.sessionThreadId) === key
|
|
13829
13833
|
},
|
|
13834
|
+
// Queue-state gate for the orphan reap (see the signal's `isClaudeBusy` doc):
|
|
13835
|
+
// while a turn is in flight the handback is enqueued behind it, not orphaned.
|
|
13836
|
+
// False for both `bridge_alive_idle` and `bridge_dead` → genuine orphan reaps.
|
|
13837
|
+
isClaudeBusy: () => isMachineInTurn(),
|
|
13830
13838
|
})
|
|
13831
13839
|
|
|
13832
13840
|
/**
|
|
@@ -54,9 +54,23 @@
|
|
|
54
54
|
* On adoption the durable record is re-keyed from its synthetic pre-turn key
|
|
55
55
|
* to the real `statusKey` so that teardown matches.
|
|
56
56
|
*
|
|
57
|
-
* • NEVER-ADOPTED ORPHAN REAP
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* • NEVER-ADOPTED ORPHAN REAP — GATED ON A GLOBAL-BUSY QUEUE CHECK. A
|
|
58
|
+
* degenerate case (bridge death after release, no enqueue ever arrives)
|
|
59
|
+
* would leave a frozen card + a forever `typing…` loop. But the reap must
|
|
60
|
+
* fire ONLY for that genuine orphan, never for a handback that is correctly
|
|
61
|
+
* enqueued and simply waiting behind a long in-flight turn: claude has ONE
|
|
62
|
+
* bridge / ONE input queue, so a released handback mints its adopting turn
|
|
63
|
+
* only when claude next goes idle. A flat age-based reap cannot tell the two
|
|
64
|
+
* apart and (before this gate) posted a false "never started — needs a
|
|
65
|
+
* nudge" card every time a parent turn out-ran the timeout (the 3-cards-in-
|
|
66
|
+
* 4-min incident). The reap therefore consults `isClaudeBusy()` — the
|
|
67
|
+
* machine-level "a turn is in flight right now" signal — and DEFERS
|
|
68
|
+
* (re-arms for another `adoptTimeoutMs`) while claude is busy, finalizing
|
|
69
|
+
* only once claude is idle AND the handback still has not been adopted. That
|
|
70
|
+
* idle-with-no-adoption state is the genuine bridge-death / dropped-inbound
|
|
71
|
+
* degenerate case. Lengthening the flat timeout would merely move the false
|
|
72
|
+
* positive; the queue-state gate removes it. The pre-turn card is persisted
|
|
73
|
+
* as a COMPLETE `ActivityCardRecord`
|
|
60
74
|
* under a SYNTHETIC `turnKey` (`preturn:<statusKey>:<startedAt>`) — synthetic
|
|
61
75
|
* so a sibling REAL turn on the same topic key can neither upsert-clobber it
|
|
62
76
|
* nor keep it "live" (the mid-session reaper's `isLive` keys on live topic
|
|
@@ -171,6 +185,22 @@ export interface HandbackPreturnSignalDeps {
|
|
|
171
185
|
* canonical turn-end owns the stop. Optional; defaults to "no live turn"
|
|
172
186
|
* (the pre-#3544 behaviour). */
|
|
173
187
|
hasLiveTurn?: (statusKey: string) => boolean
|
|
188
|
+
/** True IFF claude is CURRENTLY producing a turn (ANY topic — the machine has
|
|
189
|
+
* ONE bridge / ONE input queue). Consulted by the orphan reap: a released
|
|
190
|
+
* handback is delivered into that single queue and only mints its adopting
|
|
191
|
+
* turn when claude next goes idle, so while claude is busy the handback is
|
|
192
|
+
* NOT orphaned — it is correctly enqueued BEHIND the in-flight turn (a long
|
|
193
|
+
* parent turn can run many minutes). Finalizing it as an orphan in that
|
|
194
|
+
* window posts a false "never started — needs a nudge" card. The reap
|
|
195
|
+
* therefore DEFERS (re-arms) while this is true and finalizes only once
|
|
196
|
+
* claude is idle and the handback still has not been adopted — the genuine
|
|
197
|
+
* bridge-death / dropped-inbound degenerate case the reap exists for.
|
|
198
|
+
* Distinct from `hasLiveTurn`, which is PER-KEY and only governs the
|
|
199
|
+
* typing-loop stop; this is the GLOBAL busy signal because the handback
|
|
200
|
+
* queues behind whatever turn is running, regardless of topic. Optional;
|
|
201
|
+
* defaults to "not busy" (immediate reap — the pre-fix behaviour), so the
|
|
202
|
+
* genuine-orphan path is unchanged. */
|
|
203
|
+
isClaudeBusy?: () => boolean
|
|
174
204
|
now?: () => number
|
|
175
205
|
/** Debounce before painting the pre-turn card (kills sub-second flicker). */
|
|
176
206
|
debounceMs?: number
|
|
@@ -349,6 +379,25 @@ export function createHandbackPreturnSignal(
|
|
|
349
379
|
function reap(entry: PreTurnEntry): void {
|
|
350
380
|
entry.reapTimer = null
|
|
351
381
|
if (entry.consumed) return
|
|
382
|
+
// PRIMARY GATE (queue state, not a flat timeout): a released handback is
|
|
383
|
+
// delivered into claude's SINGLE input queue and mints its adopting turn
|
|
384
|
+
// only when claude next goes idle. If claude is STILL in a turn, this
|
|
385
|
+
// handback is not orphaned — it is correctly enqueued behind the in-flight
|
|
386
|
+
// turn (a long parent turn can run many minutes). Finalizing now would post
|
|
387
|
+
// a false "never started — needs a nudge" card (the 3-cards-in-4-min
|
|
388
|
+
// incident). Defer: re-arm and re-check. We finalize only once claude is
|
|
389
|
+
// idle AND the handback still has not been adopted (the genuine bridge-
|
|
390
|
+
// death / dropped-inbound case). This cannot loop forever on a healthy
|
|
391
|
+
// agent: claude processes its queue FIFO, so the handback is either adopted
|
|
392
|
+
// (cancelling the reap) or claude eventually goes idle (letting it fire).
|
|
393
|
+
if (deps.isClaudeBusy?.() === true) {
|
|
394
|
+
log(
|
|
395
|
+
`handback-preturn-signal: reap deferred key=${entry.statusKey} ` +
|
|
396
|
+
`(claude busy — handback enqueued behind an in-flight turn, not orphaned)\n`,
|
|
397
|
+
)
|
|
398
|
+
entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
352
401
|
entry.consumed = true
|
|
353
402
|
// Stop the forever-running typing loop and finalize the frozen card.
|
|
354
403
|
stopTypingUnlessTurnLive(entry, 'orphan reap')
|
|
@@ -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)
|
|
@@ -300,6 +300,73 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
|
|
|
300
300
|
expect(h.signal.pendingCount()).toBe(0) // map empty
|
|
301
301
|
})
|
|
302
302
|
|
|
303
|
+
// ── queued-behind-a-long-turn is NOT an orphan (the 3-cards-in-4-min bug) ──
|
|
304
|
+
// A handback released while the parent is mid-turn on a long task is delivered
|
|
305
|
+
// into claude's single input queue and mints its adopting turn only when the
|
|
306
|
+
// parent turn ends. The flat 30 s reap could not tell "enqueued and waiting"
|
|
307
|
+
// from "bridge died, never enqueued" and finalized a false "handback never
|
|
308
|
+
// started — needs a nudge" card. With the queue-state gate (`isClaudeBusy`)
|
|
309
|
+
// the reap DEFERS while claude is busy and the late adoption finalizes the
|
|
310
|
+
// card honestly. This test FAILS on the pre-fix flat-30 s behaviour: without
|
|
311
|
+
// the gate the reap fires at 30 s, finalizes the orphan text, and consumes the
|
|
312
|
+
// entry so the later adoption returns null.
|
|
313
|
+
it('does NOT finalize a handback whose turn adopts late because the parent was busy', async () => {
|
|
314
|
+
let busy = false
|
|
315
|
+
const h = makeHarness({ isClaudeBusy: () => busy })
|
|
316
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatBusy', messageId: 700 }))
|
|
317
|
+
await h.sched.advance(700) // debounce: card painted, reap armed
|
|
318
|
+
const resolvedId = await h.openCard.mock.results[0]!.value
|
|
319
|
+
expect(h.records.size).toBe(1)
|
|
320
|
+
|
|
321
|
+
// The parent turn is now in flight (a long Wave-2 assembly). The handback is
|
|
322
|
+
// enqueued behind it.
|
|
323
|
+
busy = true
|
|
324
|
+
|
|
325
|
+
// Two full reap intervals elapse — well beyond the flat 30 s. The reap must
|
|
326
|
+
// DEFER each time: no false orphan card, entry still live, typing still lit.
|
|
327
|
+
await h.sched.advance(30_000)
|
|
328
|
+
await h.sched.advance(30_000)
|
|
329
|
+
expect(h.finalizedIds).toEqual([]) // NEVER posted the "never started" nudge
|
|
330
|
+
expect(h.signal.pendingCount()).toBe(1) // still queued, not reaped
|
|
331
|
+
expect(h.typing.activeCount()).toBe(1) // indicator kept while queued
|
|
332
|
+
|
|
333
|
+
// The parent turn finally ends and the handback mints its own turn: it
|
|
334
|
+
// adopts the SAME card (an edit, not a second send / not an orphan finalize).
|
|
335
|
+
busy = false
|
|
336
|
+
const turnId = deriveTurnId('chatBusy', null, 700)!
|
|
337
|
+
const adoption = h.signal.tryAdopt(turnId)
|
|
338
|
+
expect(adoption).not.toBeNull()
|
|
339
|
+
expect(adoption!.activityMessageId).toBe(resolvedId)
|
|
340
|
+
expect(h.finalizedIds).toEqual([]) // adopted, never finalized as orphan
|
|
341
|
+
expect(h.openCard).toHaveBeenCalledTimes(1) // no double-send
|
|
342
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// The deferred reap is not a leak: once claude goes idle and the handback has
|
|
346
|
+
// STILL not been adopted (genuine bridge-death / dropped inbound), the re-armed
|
|
347
|
+
// reap finalizes honestly — the degenerate case the reap exists for.
|
|
348
|
+
it('finalizes a genuine orphan once claude goes idle, even after deferring while busy', async () => {
|
|
349
|
+
let busy = true
|
|
350
|
+
const h = makeHarness({ isClaudeBusy: () => busy })
|
|
351
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatIdleOrphan', messageId: 710 }))
|
|
352
|
+
await h.sched.advance(700)
|
|
353
|
+
const resolvedId = await h.openCard.mock.results[0]!.value
|
|
354
|
+
|
|
355
|
+
// Busy: the first reap defers.
|
|
356
|
+
await h.sched.advance(30_000)
|
|
357
|
+
expect(h.finalizedIds).toEqual([])
|
|
358
|
+
expect(h.signal.pendingCount()).toBe(1)
|
|
359
|
+
|
|
360
|
+
// Claude goes idle but NO adopting turn ever mints (the inbound was dropped).
|
|
361
|
+
// The re-armed reap now finalizes the genuine orphan.
|
|
362
|
+
busy = false
|
|
363
|
+
await h.sched.advance(30_000)
|
|
364
|
+
expect(h.finalizedIds).toEqual([resolvedId])
|
|
365
|
+
expect(h.typing.activeCount()).toBe(0)
|
|
366
|
+
expect(h.records.size).toBe(0)
|
|
367
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
368
|
+
})
|
|
369
|
+
|
|
303
370
|
it('identity race: a user inbound on the same topic never mis-adopts the handback, no double-send', async () => {
|
|
304
371
|
const h = makeHarness()
|
|
305
372
|
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatE', messageId: 200 }))
|
|
@@ -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
|
);
|