switchroom 0.18.17 → 0.18.19
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/agent-scheduler/index.js +13 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/notion-write-pretool.mjs +13 -0
- package/dist/cli/switchroom.js +605 -479
- package/dist/host-control/main.js +17 -1
- package/dist/vault/approvals/kernel-server.js +13 -0
- package/dist/vault/broker/server.js +13 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1544 -619
- package/telegram-plugin/dist/server.js +32 -1
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/format.ts +137 -213
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
- package/telegram-plugin/gateway/gateway.ts +399 -85
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +103 -0
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/llm-error-present.ts +474 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/send-gate-degraded.test.ts +152 -1
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +212 -19
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/stream-reply-handler.ts +5 -14
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
- package/telegram-plugin/tests/format-consistency.test.ts +68 -53
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/llm-error-present.test.ts +481 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
- package/telegram-plugin/tests/telegram-format.test.ts +86 -31
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/turn-flush-safety.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +509 -409
|
@@ -16994,6 +16994,31 @@ var init_plugin_logger = __esm(() => {
|
|
|
16994
16994
|
DEFAULT_LOG_PATH2 = join2(homedir2(), ".switchroom", "logs", "telegram-plugin.log");
|
|
16995
16995
|
ROTATE_AT_BYTES2 = 50 * 1024 * 1024;
|
|
16996
16996
|
});
|
|
16997
|
+
|
|
16998
|
+
// format.ts
|
|
16999
|
+
var init_format = () => {};
|
|
17000
|
+
|
|
17001
|
+
// raw-error-scrub.ts
|
|
17002
|
+
function extractRequestId(raw) {
|
|
17003
|
+
if (typeof raw !== "string" || raw.length === 0)
|
|
17004
|
+
return;
|
|
17005
|
+
const m = raw.match(/["']request[_-]?id["']\s*:\s*["']([A-Za-z0-9._-]+)["']/i) ?? raw.match(/\brequest[_-]?id[=:]\s*([A-Za-z0-9._-]+)/i);
|
|
17006
|
+
return m ? m[1] : undefined;
|
|
17007
|
+
}
|
|
17008
|
+
function truncateDetailPreservingRequestId(detail, max) {
|
|
17009
|
+
if (typeof detail !== "string")
|
|
17010
|
+
return "";
|
|
17011
|
+
if (detail.length <= max)
|
|
17012
|
+
return detail;
|
|
17013
|
+
const rid = extractRequestId(detail);
|
|
17014
|
+
const head = detail.slice(0, max);
|
|
17015
|
+
if (rid == null || head.includes(rid))
|
|
17016
|
+
return head;
|
|
17017
|
+
const suffix = ` request_id=${rid}`;
|
|
17018
|
+
const headBudget = Math.max(0, max - suffix.length);
|
|
17019
|
+
return `${detail.slice(0, headBudget)}${suffix}`;
|
|
17020
|
+
}
|
|
17021
|
+
|
|
16997
17022
|
// operator-events.ts
|
|
16998
17023
|
function classifyClaudeError(raw) {
|
|
16999
17024
|
try {
|
|
@@ -17058,6 +17083,7 @@ function getNestedObj(obj, key) {
|
|
|
17058
17083
|
}
|
|
17059
17084
|
var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS, cooldownMap;
|
|
17060
17085
|
var init_operator_events = __esm(() => {
|
|
17086
|
+
init_format();
|
|
17061
17087
|
DEFAULT_OPERATOR_EVENT_COOLDOWN_MS = 5 * 60000;
|
|
17062
17088
|
cooldownMap = new Map;
|
|
17063
17089
|
});
|
|
@@ -17077,6 +17103,7 @@ var init_text_voice_scrub = __esm(() => {
|
|
|
17077
17103
|
|
|
17078
17104
|
// card-format.ts
|
|
17079
17105
|
var init_card_format = __esm(() => {
|
|
17106
|
+
init_format();
|
|
17080
17107
|
init_text_voice_scrub();
|
|
17081
17108
|
});
|
|
17082
17109
|
|
|
@@ -17376,6 +17403,10 @@ function projectTranscriptLine(line) {
|
|
|
17376
17403
|
const content = message?.content;
|
|
17377
17404
|
if (!Array.isArray(content))
|
|
17378
17405
|
return [];
|
|
17406
|
+
if (obj.isApiErrorMessage === true) {
|
|
17407
|
+
const mainModel2 = message?.model;
|
|
17408
|
+
return typeof mainModel2 === "string" && !isModelSentinel(mainModel2) ? [{ kind: "model", model: mainModel2 }] : [];
|
|
17409
|
+
}
|
|
17379
17410
|
const events = [];
|
|
17380
17411
|
const mainModel = message?.model;
|
|
17381
17412
|
if (typeof mainModel === "string" && !isModelSentinel(mainModel)) {
|
|
@@ -25140,7 +25171,7 @@ var init_bridge = __esm(async () => {
|
|
|
25140
25171
|
type: "operator_event",
|
|
25141
25172
|
kind: ev.kind,
|
|
25142
25173
|
agent: AGENT_NAME,
|
|
25143
|
-
detail: ev.detail
|
|
25174
|
+
detail: truncateDetailPreservingRequestId(ev.detail, 1000),
|
|
25144
25175
|
chatId: ""
|
|
25145
25176
|
});
|
|
25146
25177
|
} catch (err) {
|
|
@@ -81,12 +81,24 @@ export interface FleetFallbackResumeGate {
|
|
|
81
81
|
/**
|
|
82
82
|
* Decide whether to resume the dead turn after a successful swap. Call ONLY
|
|
83
83
|
* when the swap outcome was `'switched'`. Records the arm time on a 'resume'
|
|
84
|
-
* verdict so a follow-on swap within `singleFlightMs` is suppressed.
|
|
84
|
+
* verdict so a follow-on swap within `singleFlightMs` is suppressed. Equivalent
|
|
85
|
+
* to `peek()` followed by `arm()` on a 'resume' verdict.
|
|
85
86
|
*
|
|
86
87
|
* @param failedTurnStartedAtMs epoch-ms the failed turn began, or null when
|
|
87
88
|
* unknown (then the staleness guard is deferred to the boot path).
|
|
88
89
|
*/
|
|
89
90
|
decide(failedTurnStartedAtMs: number | null): ResumeDecision;
|
|
91
|
+
/**
|
|
92
|
+
* Evaluate the resume verdict WITHOUT arming the single-flight latch. Use when
|
|
93
|
+
* the caller must perform fallible work (e.g. write a consume-once carrier)
|
|
94
|
+
* BEFORE committing to a restart: peek → do the writes → `arm()` only on
|
|
95
|
+
* success, so a throwing write can never leave an armed latch with no pending
|
|
96
|
+
* restart (which would suppress a legitimate later resume for `singleFlightMs`).
|
|
97
|
+
*/
|
|
98
|
+
peek(failedTurnStartedAtMs: number | null): ResumeDecision;
|
|
99
|
+
/** Commit the single-flight arm (records "now" as the arm time). Pair with a
|
|
100
|
+
* prior `peek()` that returned 'resume'. */
|
|
101
|
+
arm(): void;
|
|
90
102
|
/** Test seam — reset to fresh state. Production code should not call this. */
|
|
91
103
|
reset(): void;
|
|
92
104
|
/** Test/debug — current internal state. */
|
|
@@ -105,7 +117,7 @@ export function createFleetFallbackResumeGate(
|
|
|
105
117
|
// -Infinity = never resumed. A concrete value arms the single-flight window.
|
|
106
118
|
let lastResumedAtMs = Number.NEGATIVE_INFINITY;
|
|
107
119
|
|
|
108
|
-
function
|
|
120
|
+
function peek(failedTurnStartedAtMs: number | null): ResumeDecision {
|
|
109
121
|
const now = nowFn();
|
|
110
122
|
// Guard 2 — single-flight. A second swap within the window does not
|
|
111
123
|
// re-arm; the in-flight restart owns the resume.
|
|
@@ -115,12 +127,23 @@ export function createFleetFallbackResumeGate(
|
|
|
115
127
|
if (failedTurnStartedAtMs != null && now - failedTurnStartedAtMs > maxAgeMs) {
|
|
116
128
|
return 'skip-stale';
|
|
117
129
|
}
|
|
118
|
-
lastResumedAtMs = now;
|
|
119
130
|
return 'resume';
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
function arm(): void {
|
|
134
|
+
lastResumedAtMs = nowFn();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function decide(failedTurnStartedAtMs: number | null): ResumeDecision {
|
|
138
|
+
const verdict = peek(failedTurnStartedAtMs);
|
|
139
|
+
if (verdict === 'resume') arm();
|
|
140
|
+
return verdict;
|
|
141
|
+
}
|
|
142
|
+
|
|
122
143
|
return {
|
|
123
144
|
decide,
|
|
145
|
+
peek,
|
|
146
|
+
arm,
|
|
124
147
|
reset() {
|
|
125
148
|
lastResumedAtMs = Number.NEGATIVE_INFINITY;
|
|
126
149
|
},
|
|
@@ -111,6 +111,16 @@ export function escapeLinkHref(href: string): string {
|
|
|
111
111
|
* the old whole-message heuristic ("bail if any real newline exists") was
|
|
112
112
|
* too broad and prevented repair of mixed messages that had both real
|
|
113
113
|
* newlines and stray literal `\n` escape sequences outside code spans.
|
|
114
|
+
*
|
|
115
|
+
* KNOWN ISSUE (accepted tradeoff): a literal backslash sequence `\n`/`\r`/`\t`
|
|
116
|
+
* that a user genuinely meant as text OUTSIDE a code span is unescaped into a
|
|
117
|
+
* real newline/tab — e.g. a bare Windows path `C:\new\table` becomes
|
|
118
|
+
* `C:<newline>ew<tab>able`. This is deliberately not guarded: the fleet is
|
|
119
|
+
* Linux-only, such paths in prose (rather than inside a `code span`, which is
|
|
120
|
+
* masked and safe) are vanishingly rare, and the far commoner failure this
|
|
121
|
+
* repairs is an LLM emitting JSON-escaped `\n\n` as literal text. If a
|
|
122
|
+
* Windows-path use case ever matters, restrict the unescape to sequences not
|
|
123
|
+
* flanked by path-like characters rather than removing it.
|
|
114
124
|
*/
|
|
115
125
|
export function repairEscapedWhitespace(text: string): string {
|
|
116
126
|
if (!/\\[nrt"\\]/.test(text)) return text
|
|
@@ -315,13 +325,14 @@ export function normalizeParagraphBreaks(text: string): string {
|
|
|
315
325
|
// as an oversized / ragged gap in the raw text and in some clients, and
|
|
316
326
|
// it was the "stray blank line" seen in real replies. Collapse it.
|
|
317
327
|
//
|
|
318
|
-
// Deliberately ASCII-only:
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
328
|
+
// Deliberately ASCII-only: the `[ \t\r]` character class excludes U+00A0 by
|
|
329
|
+
// construction, so a line whose only content is a non-breaking space a user
|
|
330
|
+
// legitimately typed is left intact rather than silently collapsed. (This
|
|
331
|
+
// used to also protect the NBSP paragraph spacer that addParagraphSpacers
|
|
332
|
+
// injected downstream; that spacer pass was removed in the #2669 follow-up —
|
|
333
|
+
// paragraph gaps now rely on plain `\n\n` — but keeping this ASCII-only is
|
|
334
|
+
// still the conservative choice.) Runs on code-masked text, so a blank-ish
|
|
335
|
+
// line inside a fenced block is parked and never touched.
|
|
325
336
|
let out = masked
|
|
326
337
|
// Collapse any run of newlines interleaved with ASCII whitespace-only
|
|
327
338
|
// interior lines down to a single clean `\n\n`. Requires at least one
|
|
@@ -469,184 +480,14 @@ export function hardenCardBreaks(text: string): string {
|
|
|
469
480
|
}
|
|
470
481
|
|
|
471
482
|
// ---------------------------------------------------------------------------
|
|
472
|
-
// Paragraph
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
* paragraph break TIGHT — the two paragraphs sit on adjacent lines with no
|
|
481
|
-
* visible empty line between them. The legacy markdown→HTML path (removed in
|
|
482
|
-
* #2669) sent `\n\n` literally with `parse_mode:"HTML"`, where two newlines
|
|
483
|
-
* render as a real blank line. That regression is the operator-confirmed
|
|
484
|
-
* "paragraphs jammed together" symptom.
|
|
485
|
-
*
|
|
486
|
-
* CommonMark discards blank lines made of ASCII whitespace, but a line whose
|
|
487
|
-
* only content is a NON-breaking space (U+00A0) is a genuine, non-empty
|
|
488
|
-
* paragraph — it renders as a visible empty line. So `A\n\n \n\nB`
|
|
489
|
-
* renders as three paragraphs: A, a blank-looking line, then B — the visible
|
|
490
|
-
* gap the HTML path used to produce.
|
|
491
|
-
*/
|
|
492
|
-
export const PARAGRAPH_SPACER = ' '
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Insert a visible blank-line spacer into each genuine `\n\n` paragraph gap so
|
|
496
|
-
* the rich GFM renderer shows a real empty line between paragraphs (matching
|
|
497
|
-
* the pre-#2669 HTML behaviour). See PARAGRAPH_SPACER for why a U+00A0 line is
|
|
498
|
-
* the reliable trick.
|
|
499
|
-
*
|
|
500
|
-
* Uniform-block-spacing contract: a spacer is inserted into EVERY `\n\n` gap
|
|
501
|
-
* that separates two DISTINCT blocks — prose→prose, paragraph→list,
|
|
502
|
-
* list→paragraph, heading→anything, blockquote/table/fence boundaries — so a
|
|
503
|
-
* mixed message renders with one identical visible blank line between blocks.
|
|
504
|
-
* The one exception is a gap INSIDE a block of the same structural kind (two
|
|
505
|
-
* items of a loose list, consecutive table rows/quotes/fences): those stay
|
|
506
|
-
* tight so the block's contiguity survives. Interiors joined by a single `\n`
|
|
507
|
-
* are never gaps at all and are untouched by construction.
|
|
508
|
-
*
|
|
509
|
-
* Runs on code-masked text (so a blank line inside a fenced block is never
|
|
510
|
-
* touched) and is idempotent — a gap that already contains a U+00A0 spacer
|
|
511
|
-
* paragraph is recognised and never doubled.
|
|
512
|
-
*
|
|
513
|
-
* Intended to run in the outbound send path AFTER normalizeParagraphBreaks,
|
|
514
|
-
* which has already collapsed 3+ newline runs to `\n\n`, promoted lone prose
|
|
515
|
-
* breaks, and guaranteed block-boundary blank lines. normalizeParagraphBreaks
|
|
516
|
-
* itself deliberately does NOT do this so its (well-tested) `\n\n`-preserving
|
|
517
|
-
* contract is unchanged.
|
|
518
|
-
*/
|
|
519
|
-
export function addParagraphSpacers(text: string): string {
|
|
520
|
-
if (!text.includes('\n\n')) return text
|
|
521
|
-
|
|
522
|
-
const nonce = Math.random().toString(36).slice(2)
|
|
523
|
-
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
524
|
-
|
|
525
|
-
if (!masked.includes('\n\n')) return restore(masked)
|
|
526
|
-
|
|
527
|
-
// The line we inject for a spacer paragraph (its only content is U+00A0).
|
|
528
|
-
const spacerLine = PARAGRAPH_SPACER
|
|
529
|
-
|
|
530
|
-
// CRITICAL: `String.prototype.trim()` strips U+00A0, so a spacer line would
|
|
531
|
-
// read as "blank" and the pass would lose idempotency (re-spacing an
|
|
532
|
-
// already-spaced gap). Detect blank-ness with an ASCII-whitespace-only test
|
|
533
|
-
// so the U+00A0 spacer line is correctly seen as NON-blank.
|
|
534
|
-
const isBlankLine = (line: string): boolean => /^[ \t\r\f\v]*$/.test(line)
|
|
535
|
-
// Trim ASCII-only (preserve U+00A0) so the spacer line is recognisable.
|
|
536
|
-
const asciiTrim = (line: string): string => line.replace(/^[ \t\r\f\v]+|[ \t\r\f\v]+$/g, '')
|
|
537
|
-
|
|
538
|
-
// Classify the block kind of a facing line so the spacer decision can be
|
|
539
|
-
// made per BLOCK TRANSITION (#uniform-block-spacing). A spacer is inserted
|
|
540
|
-
// at every `\n\n` gap between two DIFFERENT block kinds (paragraph→list,
|
|
541
|
-
// list→paragraph, heading→anything, blockquote/table boundaries) and
|
|
542
|
-
// between two prose paragraphs — but NEVER inside a single block's interior
|
|
543
|
-
// (between two items of the same loose list, two rows of a table, two
|
|
544
|
-
// quote lines, two fenced blocks). One visible blank line between distinct
|
|
545
|
-
// blocks, identical everywhere; list/table interiors stay tight.
|
|
546
|
-
type BlockKind = 'spacer' | 'list' | 'table' | 'quote' | 'heading' | 'fence' | 'divider' | 'prose'
|
|
547
|
-
const blockKind = (line: string): BlockKind => {
|
|
548
|
-
if (asciiTrim(line) === spacerLine) return 'spacer'
|
|
549
|
-
if (isFenceOpenLine(line, placeholder)) return 'fence'
|
|
550
|
-
if (isListItemLine(line)) return 'list'
|
|
551
|
-
if (isTableRowLine(line) || isTableDelimiterLine(line)) return 'table'
|
|
552
|
-
if (isBlockquoteLine(line)) return 'quote'
|
|
553
|
-
if (isHeadingLine(line)) return 'heading'
|
|
554
|
-
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line.trimStart())) return 'divider'
|
|
555
|
-
return 'prose'
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Same-kind structural pairs whose `\n\n` gap is a block INTERIOR (a loose
|
|
559
|
-
// list's item gap, consecutive tables/quotes/fences) — no spacer there.
|
|
560
|
-
const SAME_KIND_TIGHT: ReadonlySet<BlockKind> = new Set([
|
|
561
|
-
'list',
|
|
562
|
-
'table',
|
|
563
|
-
'quote',
|
|
564
|
-
'fence',
|
|
565
|
-
'divider',
|
|
566
|
-
])
|
|
567
|
-
|
|
568
|
-
const shouldSpaceGap = (above: string, below: string): boolean => {
|
|
569
|
-
const a = blockKind(above)
|
|
570
|
-
const b = blockKind(below)
|
|
571
|
-
// A facing spacer line means the gap is already spaced (idempotency —
|
|
572
|
-
// also guarded by alreadySpaced at the call site).
|
|
573
|
-
if (a === 'spacer' || b === 'spacer') return false
|
|
574
|
-
if (a === b && SAME_KIND_TIGHT.has(a)) return false
|
|
575
|
-
// Everything else is a genuine block transition (incl. prose→prose,
|
|
576
|
-
// heading→anything, list↔paragraph, table/quote boundaries) — space it.
|
|
577
|
-
return true
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// Split into blank-line-delimited segments, then re-join inserting a spacer
|
|
581
|
-
// paragraph between two adjacent NON-blank segments whose facing lines are
|
|
582
|
-
// both prose and which are not already separated by a spacer.
|
|
583
|
-
// A `\n\n` paragraph gap is a SINGLE blank entry between two content lines
|
|
584
|
-
// (`"A\n\nB".split('\n')` → `["A", "", "B"]`). normalizeParagraphBreaks has
|
|
585
|
-
// already collapsed 3+ newline runs to exactly `\n\n`, so we only ever see a
|
|
586
|
-
// one-blank gap here; a multi-blank run is handled defensively the same way
|
|
587
|
-
// (the FIRST blank of the run carries the spacer decision).
|
|
588
|
-
const lines = masked.split('\n')
|
|
589
|
-
const out: string[] = []
|
|
590
|
-
for (let i = 0; i < lines.length; i++) {
|
|
591
|
-
const line = lines[i]
|
|
592
|
-
const isBlank = isBlankLine(line)
|
|
593
|
-
// The spacer decision is made at the FIRST blank of a gap, i.e. when the
|
|
594
|
-
// previously emitted line is non-blank prose. Inject the spacer BEFORE the
|
|
595
|
-
// blank so the result is `above \n\n \n\n below`.
|
|
596
|
-
if (isBlank) {
|
|
597
|
-
const prevEmitted = out.length > 0 ? out[out.length - 1] : null
|
|
598
|
-
const prevIsBlank = prevEmitted != null && isBlankLine(prevEmitted)
|
|
599
|
-
if (!prevIsBlank) {
|
|
600
|
-
const above = lastNonBlank(out, isBlankLine)
|
|
601
|
-
const below = nextNonBlank(lines, i + 1, isBlankLine)
|
|
602
|
-
const alreadySpaced =
|
|
603
|
-
(above != null && asciiTrim(above) === spacerLine) ||
|
|
604
|
-
(below != null && asciiTrim(below) === spacerLine)
|
|
605
|
-
if (
|
|
606
|
-
!alreadySpaced &&
|
|
607
|
-
above != null &&
|
|
608
|
-
below != null &&
|
|
609
|
-
shouldSpaceGap(above, below)
|
|
610
|
-
) {
|
|
611
|
-
// Emit: blank, spacer paragraph, blank — a U+00A0 paragraph wedged
|
|
612
|
-
// between two real blank lines so CommonMark renders it as a visible
|
|
613
|
-
// empty line between the two prose paragraphs.
|
|
614
|
-
out.push('')
|
|
615
|
-
out.push(spacerLine)
|
|
616
|
-
out.push('')
|
|
617
|
-
continue
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
out.push(line)
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
return restore(out.join('\n'))
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* Last non-blank entry already emitted into `arr`, or null. `isBlank` is the
|
|
629
|
-
* caller's blank test (ASCII-only, so a U+00A0 spacer line counts as
|
|
630
|
-
* non-blank — `String.trim()` would wrongly strip it).
|
|
631
|
-
*/
|
|
632
|
-
function lastNonBlank(arr: string[], isBlank: (s: string) => boolean): string | null {
|
|
633
|
-
for (let i = arr.length - 1; i >= 0; i--) {
|
|
634
|
-
if (!isBlank(arr[i])) return arr[i]
|
|
635
|
-
}
|
|
636
|
-
return null
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/** First non-blank line at or after index `from` in `lines`, or null. */
|
|
640
|
-
function nextNonBlank(
|
|
641
|
-
lines: string[],
|
|
642
|
-
from: number,
|
|
643
|
-
isBlank: (s: string) => boolean,
|
|
644
|
-
): string | null {
|
|
645
|
-
for (let i = from; i < lines.length; i++) {
|
|
646
|
-
if (!isBlank(lines[i])) return lines[i]
|
|
647
|
-
}
|
|
648
|
-
return null
|
|
649
|
-
}
|
|
483
|
+
// Paragraph spacing note (#2669 follow-up): the NBSP paragraph-spacer
|
|
484
|
+
// (PARAGRAPH_SPACER / addParagraphSpacers) was REMOVED. Its premise — that the
|
|
485
|
+
// Bot API 10.1 rich (GFM) renderer collapses a `\n\n` gap TIGHT — is false for
|
|
486
|
+
// the live renderer, which shows a `\n\n` gap as a normal single blank line.
|
|
487
|
+
// The old U+00A0 spacer therefore injected a spurious SECOND blank line
|
|
488
|
+
// (`\n\n \n\n`) between every paragraph fleet-wide. Paragraph spacing now
|
|
489
|
+
// relies on the plain `\n\n` that normalizeParagraphBreaks already guarantees
|
|
490
|
+
// at block boundaries — one correct single blank line, no spacer pass.
|
|
650
491
|
|
|
651
492
|
// ---------------------------------------------------------------------------
|
|
652
493
|
// Punctuation / bullet normalization — fleet-wide consistent typography
|
|
@@ -678,7 +519,41 @@ export function normalizePunctuation(text: string): string {
|
|
|
678
519
|
const nonce = Math.random().toString(36).slice(2)
|
|
679
520
|
const { masked, restore } = maskCodeRegions(text, nonce)
|
|
680
521
|
|
|
681
|
-
|
|
522
|
+
// Mask inline-link DESTINATIONS `](href)` before the dash passes so a dash
|
|
523
|
+
// inside a URL is never rewritten. maskCodeRegions only masks code spans/
|
|
524
|
+
// fences, not link hrefs, so without this an en-dash in a path becomes a
|
|
525
|
+
// silently-wrong URL, and an em-dash becomes `, ` — the injected space
|
|
526
|
+
// TERMINATES the markdown link and leaks the trailing text as prose
|
|
527
|
+
// (`[a](https://x/foo—bar)` → `[a](https://x/foo, bar)`). Only the href
|
|
528
|
+
// inside the parens is masked; the visible link LABEL still normalizes like
|
|
529
|
+
// ordinary prose (dashes in label text are intended, per existing behaviour).
|
|
530
|
+
const linkMasks: string[] = []
|
|
531
|
+
const LINK_MASK_PH = `\x00RML${nonce}_`
|
|
532
|
+
const maskedLinks = masked.replace(/(\]\()([^)\n]*)(\))/g, (_m, open: string, href: string, close: string) => {
|
|
533
|
+
const idx = linkMasks.length
|
|
534
|
+
linkMasks.push(href)
|
|
535
|
+
return `${open}${LINK_MASK_PH}${idx}\x00${close}`
|
|
536
|
+
})
|
|
537
|
+
// Also protect GFM ANGLE-BRACKET AUTOLINK destinations `<scheme:…>` for the
|
|
538
|
+
// same reason (`<https://x/foo–bar>` → the en-dash would be rewritten to `-`,
|
|
539
|
+
// corrupting the URL). Only the URI inside the brackets is masked; the `<`/`>`
|
|
540
|
+
// are untouched. Conservative — requires a scheme-like `xxx:` prefix and no
|
|
541
|
+
// whitespace/`>` in the body (loosely mirrors the GFM absolute-URI autolink
|
|
542
|
+
// rule), so arbitrary `<…>` prose is never masked.
|
|
543
|
+
const maskedAutolinks = maskedLinks.replace(
|
|
544
|
+
/(<)([a-zA-Z][a-zA-Z0-9+.-]*:[^>\s]*)(>)/g,
|
|
545
|
+
(_m, lt: string, uri: string, gt: string) => {
|
|
546
|
+
const idx = linkMasks.length
|
|
547
|
+
linkMasks.push(uri)
|
|
548
|
+
return `${lt}${LINK_MASK_PH}${idx}\x00${gt}`
|
|
549
|
+
},
|
|
550
|
+
)
|
|
551
|
+
const escNonce = nonce.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
552
|
+
const linkRestoreRe = new RegExp(`\x00RML${escNonce}_(\\d+)\x00`, 'g')
|
|
553
|
+
const restoreLinks = (s: string): string =>
|
|
554
|
+
s.replace(linkRestoreRe, (_m, idx: string) => linkMasks[Number(idx)] ?? _m)
|
|
555
|
+
|
|
556
|
+
let out = maskedAutolinks
|
|
682
557
|
// 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
|
|
683
558
|
// flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
|
|
684
559
|
// dashes ("a — b — c") all normalize in one pass — a consumed \S would
|
|
@@ -694,6 +569,10 @@ export function normalizePunctuation(text: string): string {
|
|
|
694
569
|
// 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
|
|
695
570
|
.replace(/(\w)–(?=\w)/g, '$1-')
|
|
696
571
|
|
|
572
|
+
// Restore link hrefs now that the dash passes are done — before the bullet
|
|
573
|
+
// pass and the code restore.
|
|
574
|
+
out = restoreLinks(out)
|
|
575
|
+
|
|
697
576
|
// 4. Leading unicode bullet markers → GFM `- ` (per line, indent kept).
|
|
698
577
|
out = out
|
|
699
578
|
.split('\n')
|
|
@@ -884,9 +763,10 @@ function ensureBlockBoundaries(text: string, placeholder?: string): string {
|
|
|
884
763
|
const startsHeading = isHeadingLine(line) && !isHeadingLine(prev)
|
|
885
764
|
// List start glued to prose above by a single `\n` (uniform-block-
|
|
886
765
|
// spacing): a `- ` line already interrupts a paragraph in CommonMark,
|
|
887
|
-
// so the blank line is render-safe — it
|
|
888
|
-
//
|
|
889
|
-
// item) so list interiors stay
|
|
766
|
+
// so the blank line is render-safe — it makes the prose→list transition
|
|
767
|
+
// a real `\n\n` block boundary the GFM renderer honours. Never fires
|
|
768
|
+
// between two list items (prev is a list item) so list interiors stay
|
|
769
|
+
// tight.
|
|
890
770
|
const startsList = isListItemLine(line) && !isListItemLine(prev)
|
|
891
771
|
|
|
892
772
|
if (startsTableHere || startsFence || startsQuote || startsHeading || startsList) {
|
|
@@ -1096,6 +976,12 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
|
|
|
1096
976
|
cut = backOffOpenFence(rest, cut)
|
|
1097
977
|
// Back off so the cut doesn't bisect a table row (a line with `|`).
|
|
1098
978
|
cut = backOffTableRow(rest, cut)
|
|
979
|
+
// Back off so the cut doesn't bisect an inline entity (`**bold**`,
|
|
980
|
+
// `` `code` ``, `_italic_`, `[label](href)`), which would leave an unclosed
|
|
981
|
+
// delimiter in the emitted chunk (Telegram then parse-rejects it to
|
|
982
|
+
// plaintext, or mis-renders the continuation). Runs LAST so it also cleans
|
|
983
|
+
// up a boundary the fence/table back-offs landed on.
|
|
984
|
+
cut = backOffOpenInline(rest, cut)
|
|
1099
985
|
|
|
1100
986
|
if (cut <= 0) {
|
|
1101
987
|
// Could not find a safe boundary below maxLen — the region is one
|
|
@@ -1120,38 +1006,26 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
|
|
|
1120
1006
|
}
|
|
1121
1007
|
|
|
1122
1008
|
/**
|
|
1123
|
-
* Strip stray
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
* U+00A0 spacer line, nor a prior chunk that ENDS with one.
|
|
1009
|
+
* Strip stray blank lines off a chunk boundary so a cut that lands in a `\n\n`
|
|
1010
|
+
* paragraph gap never leaves a continuation chunk that OPENS with a bare blank
|
|
1011
|
+
* line, nor a prior chunk that ENDS with one.
|
|
1127
1012
|
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1133
|
-
* has nothing left to strip.
|
|
1013
|
+
* (Pre-#2669-follow-up this also peeled the NBSP paragraph spacer that
|
|
1014
|
+
* addParagraphSpacers injected into every gap. That spacer pass was removed —
|
|
1015
|
+
* gaps are now plain `\n\n` — so this reduces to the original behaviour: strip
|
|
1016
|
+
* a run of ASCII-whitespace-only blank lines off the boundary.) Idempotent: a
|
|
1017
|
+
* chunk already trimmed has nothing left to strip.
|
|
1134
1018
|
*
|
|
1135
1019
|
* - `'leading'` → strip the run from the START (the continuation chunk).
|
|
1136
1020
|
* - `'trailing'` → strip the run from the END (the just-emitted prior chunk).
|
|
1137
1021
|
*/
|
|
1138
1022
|
function stripBoundarySpacers(chunk: string, side: 'leading' | 'trailing'): string {
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
// with leading/trailing \n) is what we peel off the boundary.
|
|
1142
|
-
const sp = PARAGRAPH_SPACER
|
|
1023
|
+
// A boundary blank run is one-or-more lines that render empty (ASCII
|
|
1024
|
+
// whitespace only). Peel it off the requested side.
|
|
1143
1025
|
if (side === 'leading') {
|
|
1144
|
-
|
|
1145
|
-
return chunk.replace(
|
|
1146
|
-
new RegExp(`^(?:[ \\t]*${sp}?[ \\t]*\\n+)+`),
|
|
1147
|
-
'',
|
|
1148
|
-
)
|
|
1026
|
+
return chunk.replace(/^(?:[ \t]*\n)+/, '')
|
|
1149
1027
|
}
|
|
1150
|
-
|
|
1151
|
-
return chunk.replace(
|
|
1152
|
-
new RegExp(`(?:\\n+[ \\t]*${sp}?[ \\t]*)+$`),
|
|
1153
|
-
'',
|
|
1154
|
-
)
|
|
1028
|
+
return chunk.replace(/(?:\n[ \t]*)+$/, '')
|
|
1155
1029
|
}
|
|
1156
1030
|
|
|
1157
1031
|
/**
|
|
@@ -1190,3 +1064,53 @@ function backOffTableRow(text: string, cut: number): number {
|
|
|
1190
1064
|
}
|
|
1191
1065
|
return cut
|
|
1192
1066
|
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Inline entities that must not be bisected by a chunk cut. Each pattern is
|
|
1070
|
+
* matched over the full `text`; if the chosen `cut` lands STRICTLY inside a
|
|
1071
|
+
* matched span, retreat to that span's start so the whole span moves to the
|
|
1072
|
+
* next chunk (mirrors backOffOpenFence / backOffTableRow). Cutting inside a
|
|
1073
|
+
* span would strand an unclosed `***`/`**`/`*` / `` ` `` / `___`/`__`/`_` /
|
|
1074
|
+
* `](` delimiter, which Telegram parse-rejects to plaintext or mis-renders
|
|
1075
|
+
* across the boundary.
|
|
1076
|
+
*
|
|
1077
|
+
* The TRIPLE-marker patterns (`***bold-italic***` / `___…___`) come FIRST so
|
|
1078
|
+
* their whole span wins the earliest-start back-off in backOffOpenInline: the
|
|
1079
|
+
* double-marker pattern would otherwise match the inner `**…**` of a `***…***`
|
|
1080
|
+
* span and retreat only past that, stranding the lone outer `*` (odd asterisk
|
|
1081
|
+
* count → the italic is lost).
|
|
1082
|
+
*
|
|
1083
|
+
* The `_italic_` pattern is boundary-guarded so snake_case identifiers
|
|
1084
|
+
* (`foo_bar_baz`) don't read as emphasis; a stray match there is harmless
|
|
1085
|
+
* anyway (it only shifts the cut to a `_` character, still a clean boundary).
|
|
1086
|
+
*/
|
|
1087
|
+
const INLINE_SPAN_PATTERNS: readonly RegExp[] = [
|
|
1088
|
+
/`[^`\n]+`/g, // inline code
|
|
1089
|
+
/\*\*\*[^*\n]+\*\*\*/g, // bold-italic (triple) — before the bold pattern
|
|
1090
|
+
/___[^_\n]+___/g, // bold-italic underscore (triple)
|
|
1091
|
+
/\*\*[^*\n]+\*\*/g, // bold
|
|
1092
|
+
/__[^_\n]+__/g, // underline
|
|
1093
|
+
/(?<![\w*])_[^_\n]+_(?![\w*])/g, // italic (snake_case-guarded)
|
|
1094
|
+
/\[[^\]\n]*\]\([^)\n]*\)/g, // link [label](href)
|
|
1095
|
+
]
|
|
1096
|
+
|
|
1097
|
+
function backOffOpenInline(text: string, cut: number): number {
|
|
1098
|
+
if (cut <= 0 || cut >= text.length) return cut
|
|
1099
|
+
let earliest = cut
|
|
1100
|
+
for (const re of INLINE_SPAN_PATTERNS) {
|
|
1101
|
+
re.lastIndex = 0
|
|
1102
|
+
let m: RegExpExecArray | null
|
|
1103
|
+
while ((m = re.exec(text)) !== null) {
|
|
1104
|
+
const start = m.index
|
|
1105
|
+
const end = start + m[0].length
|
|
1106
|
+
// Cut strictly inside this span → the span straddles the boundary.
|
|
1107
|
+
if (start < cut && cut < end && start < earliest) earliest = start
|
|
1108
|
+
// Matches arrive in order; once a span starts at/after the cut, no later
|
|
1109
|
+
// span can contain it.
|
|
1110
|
+
if (start >= cut) break
|
|
1111
|
+
// Guard against a zero-width match wedging the loop.
|
|
1112
|
+
if (re.lastIndex === start) re.lastIndex = start + 1
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return earliest
|
|
1116
|
+
}
|
|
@@ -516,6 +516,55 @@ export function isHeldUndeliverable(
|
|
|
516
516
|
return pend.undeliverable != null
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
/** The fields the on-delivery reset reads and mutates. */
|
|
520
|
+
export interface DeliveredPermissionEntry {
|
|
521
|
+
undeliverable?: UndeliverableMark | null
|
|
522
|
+
redeliveryFailures?: number
|
|
523
|
+
/** When the operator's decision window began — the TTL is measured from here. */
|
|
524
|
+
startedAt: number
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* A HELD card just LANDED — the operator can finally see and tap it. Clear the
|
|
529
|
+
* hold marks and RESTART the TTL clock, as ONE shared decision used by BOTH
|
|
530
|
+
* gateway.ts and the outcome test's harness.
|
|
531
|
+
*
|
|
532
|
+
* WHY THIS IS SHARED CODE AND NOT AN INLINE BLOCK (#3128):
|
|
533
|
+
*
|
|
534
|
+
* The harness used to carry a PRIVATE copy of this reset (its own
|
|
535
|
+
* `live.startedAt = clock.now()`), so the behavioural test that proves the
|
|
536
|
+
* operator gets a FULL window after re-delivery drove the double, not the real
|
|
537
|
+
* gateway. Deleting `startedAt = Date.now()` from gateway.ts would have left that
|
|
538
|
+
* test GREEN — only a source-text grep noticed, and greps drift. Same placebo-pin
|
|
539
|
+
* class as the leash (#3123) and the never-auto-approve pin (#3126). Now the reset
|
|
540
|
+
* lives here, in one importable place, and both callers drive it: delete
|
|
541
|
+
* `entry.startedAt = now` below and the `(d2)` outcome test goes RED, because there
|
|
542
|
+
* is only one implementation to delete.
|
|
543
|
+
*
|
|
544
|
+
* RESET THE TTL CLOCK is load-bearing, not cosmetic. `startedAt` is when the
|
|
545
|
+
* agent asked; the TTL measures how long the operator had to answer. Until the
|
|
546
|
+
* card lands they have NOTHING to answer — it did not exist in any chat. Without
|
|
547
|
+
* the reset, a card held through a 4.6h ban lands already-expired against a 60-min
|
|
548
|
+
* TTL and the very next reaper tick auto-denies it: the silent denial would be
|
|
549
|
+
* MOVED, not removed.
|
|
550
|
+
*
|
|
551
|
+
* Returns true when this was a HELD-card recovery — the caller should reconcile
|
|
552
|
+
* the off-Telegram surface and log the re-delivery. Returns false for a normal
|
|
553
|
+
* first delivery (the entry was never held), where there is nothing to reset.
|
|
554
|
+
*
|
|
555
|
+
* @see reference/invariants.md § no-self-escalation, § on-leash
|
|
556
|
+
*/
|
|
557
|
+
export function applyDeliveredHoldReset(
|
|
558
|
+
entry: DeliveredPermissionEntry,
|
|
559
|
+
now: number,
|
|
560
|
+
): boolean {
|
|
561
|
+
if (entry.undeliverable == null) return false
|
|
562
|
+
entry.undeliverable = null
|
|
563
|
+
entry.redeliveryFailures = 0
|
|
564
|
+
entry.startedAt = now
|
|
565
|
+
return true
|
|
566
|
+
}
|
|
567
|
+
|
|
519
568
|
/**
|
|
520
569
|
* Per-tick re-delivery cap (PR 2).
|
|
521
570
|
*
|