switchroom 0.18.25 → 0.18.27
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/README.md +6 -2
- package/dist/cli/ms-365-write-pretool.mjs +4953 -14
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +16 -0
- package/telegram-plugin/dist/gateway/gateway.js +832 -38
- package/telegram-plugin/flushed-turn-supersede.ts +58 -0
- package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +305 -41
- package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
- package/telegram-plugin/gateway/model-command.ts +68 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/send-gate.test.ts +138 -0
- package/telegram-plugin/send-gate.ts +104 -1
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
- package/telegram-plugin/tests/effort-command.test.ts +47 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
- package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
- package/telegram-plugin/tests/model-command.test.ts +112 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
- package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
- package/telegram-plugin/worker-activity-feed.ts +91 -1
|
@@ -57,10 +57,27 @@ export interface Ms365WritePreview {
|
|
|
57
57
|
/** Byte delta — present only for OneDrive uploads with known sizes. */
|
|
58
58
|
sizeBytesBefore?: number;
|
|
59
59
|
sizeBytesAfter?: number;
|
|
60
|
+
/**
|
|
61
|
+
* "start → end" human string for calendar events, resolved from Graph.
|
|
62
|
+
* Present only when the opaque event id resolved successfully (#3267).
|
|
63
|
+
*/
|
|
64
|
+
eventWhen?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Structural before→after diff for the fields the mutation changes
|
|
67
|
+
* (calendar body/location/time). Present only when resolved (#3267).
|
|
68
|
+
*/
|
|
69
|
+
changes?: Ms365PreviewChange[];
|
|
60
70
|
/** 1-line agent rationale — advisory; operator should not over-trust. */
|
|
61
71
|
agentRationale?: string;
|
|
62
72
|
}
|
|
63
73
|
|
|
74
|
+
/** A single before→after change rendered on the card. */
|
|
75
|
+
export interface Ms365PreviewChange {
|
|
76
|
+
field: string;
|
|
77
|
+
before?: string;
|
|
78
|
+
after?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
/**
|
|
65
82
|
* Validate a wire payload into a typed Ms365WritePreview. Returns null
|
|
66
83
|
* on malformed input (defense in depth — the hook is trusted but the
|
|
@@ -84,10 +101,32 @@ export function validateMs365Preview(input: unknown): Ms365WritePreview | null {
|
|
|
84
101
|
if (typeof o.deepLink === "string") out.deepLink = o.deepLink;
|
|
85
102
|
if (typeof o.sizeBytesBefore === "number") out.sizeBytesBefore = o.sizeBytesBefore;
|
|
86
103
|
if (typeof o.sizeBytesAfter === "number") out.sizeBytesAfter = o.sizeBytesAfter;
|
|
104
|
+
if (typeof o.eventWhen === "string") out.eventWhen = o.eventWhen;
|
|
105
|
+
const changes = sanitizeChanges(o.changes);
|
|
106
|
+
if (changes) out.changes = changes;
|
|
87
107
|
if (typeof o.agentRationale === "string") out.agentRationale = o.agentRationale;
|
|
88
108
|
return out;
|
|
89
109
|
}
|
|
90
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Validate the wire `changes` array into typed before→after entries. Drops
|
|
113
|
+
* malformed entries defensively; returns undefined when nothing usable.
|
|
114
|
+
*/
|
|
115
|
+
function sanitizeChanges(input: unknown): Ms365PreviewChange[] | undefined {
|
|
116
|
+
if (!Array.isArray(input)) return undefined;
|
|
117
|
+
const out: Ms365PreviewChange[] = [];
|
|
118
|
+
for (const raw of input) {
|
|
119
|
+
if (!raw || typeof raw !== "object") continue;
|
|
120
|
+
const c = raw as Record<string, unknown>;
|
|
121
|
+
if (typeof c.field !== "string" || c.field.length === 0) continue;
|
|
122
|
+
const entry: Ms365PreviewChange = { field: c.field };
|
|
123
|
+
if (typeof c.before === "string") entry.before = c.before;
|
|
124
|
+
if (typeof c.after === "string") entry.after = c.after;
|
|
125
|
+
out.push(entry);
|
|
126
|
+
}
|
|
127
|
+
return out.length > 0 ? out : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
91
130
|
// ────────────────────────────────────────────────────────────────────────
|
|
92
131
|
// Handler — DI shape mirrors DriveApprovalHandlerDeps
|
|
93
132
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -168,6 +207,9 @@ export function buildMs365CardText(p: Ms365WritePreview): string {
|
|
|
168
207
|
lines.push(`ID: ${truncate(p.itemId, 96)}`);
|
|
169
208
|
}
|
|
170
209
|
lines.push(`Account: ${truncate(p.accountEmail, 96)}`);
|
|
210
|
+
if (p.eventWhen) {
|
|
211
|
+
lines.push(`When: ${truncate(p.eventWhen, 96)}`);
|
|
212
|
+
}
|
|
171
213
|
if (
|
|
172
214
|
typeof p.sizeBytesBefore === "number" ||
|
|
173
215
|
typeof p.sizeBytesAfter === "number"
|
|
@@ -181,13 +223,26 @@ export function buildMs365CardText(p: Ms365WritePreview): string {
|
|
|
181
223
|
if (p.deepLink) {
|
|
182
224
|
lines.push(`Link: ${truncate(p.deepLink, 256)}`);
|
|
183
225
|
}
|
|
226
|
+
if (p.changes && p.changes.length > 0) {
|
|
227
|
+
lines.push("");
|
|
228
|
+
lines.push("Changes:");
|
|
229
|
+
for (const c of p.changes.slice(0, 8)) {
|
|
230
|
+
const before = c.before !== undefined ? truncate(c.before, 96) : "(none)";
|
|
231
|
+
const after = c.after !== undefined ? truncate(c.after, 96) : "(cleared)";
|
|
232
|
+
lines.push(`• ${c.field}: ${before} → ${after}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
184
235
|
if (p.agentRationale) {
|
|
185
236
|
lines.push("");
|
|
186
237
|
lines.push(`💬 ${truncate(p.agentRationale, 512)}`);
|
|
187
238
|
}
|
|
188
239
|
lines.push("");
|
|
240
|
+
// With a resolved structural diff present the operator is no longer
|
|
241
|
+
// approving a blind write; soften the attestation warning accordingly.
|
|
189
242
|
lines.push(
|
|
190
|
-
|
|
243
|
+
p.changes && p.changes.length > 0
|
|
244
|
+
? "⚠️ Attestation (RFC §8 v1.5): the diff above is derived from live Graph state + the mutation payload. Verify before approving."
|
|
245
|
+
: "⚠️ Weak attestation (RFC §8 v1): operator should click through to verify the actual change before approving. Structural diff coming v1.5.",
|
|
191
246
|
);
|
|
192
247
|
// hardenCardBreaks: labelled field lines (Agent:/Tool:/Item:/Account:/Size:…)
|
|
193
248
|
// would soft-collapse into one blob under the GFM rich renderer; this card is
|
|
@@ -196,8 +251,15 @@ export function buildMs365CardText(p: Ms365WritePreview): string {
|
|
|
196
251
|
}
|
|
197
252
|
|
|
198
253
|
function truncate(s: string, n: number): string {
|
|
199
|
-
|
|
200
|
-
|
|
254
|
+
// Collapse control whitespace (newline / carriage-return / tab) to a single
|
|
255
|
+
// space FIRST — every field on this card is single-line, and this card is a
|
|
256
|
+
// security decision surface. A Graph-sourced value like an event subject of
|
|
257
|
+
// `Team sync\nAccount: attacker@x` would otherwise inject a fake-looking
|
|
258
|
+
// labelled line onto the card (#3267 review Finding 2). Length-truncation
|
|
259
|
+
// alone does not defend against this.
|
|
260
|
+
const oneLine = s.replace(/[\r\n\t]+/g, " ");
|
|
261
|
+
if (oneLine.length <= n) return oneLine;
|
|
262
|
+
return oneLine.slice(0, n - 1) + "…";
|
|
201
263
|
}
|
|
202
264
|
|
|
203
265
|
function humanBytes(bytes: number): string {
|
|
@@ -115,6 +115,18 @@ export function buildSubagentHandbackInbound(opts: {
|
|
|
115
115
|
meta: {
|
|
116
116
|
source: 'subagent_handback',
|
|
117
117
|
outcome: opts.ctx.outcome,
|
|
118
|
+
// #3268 — round-trip the fabricated `ts` through `meta.message_id` so it
|
|
119
|
+
// survives to enqueue. `ev.messageId` at enqueue is parsed from the
|
|
120
|
+
// channel envelope's `message_id` attribute, which is rendered ONLY from
|
|
121
|
+
// `meta.message_id` — the top-level `messageId` field does NOT survive the
|
|
122
|
+
// bridge. Without this, enqueue's `deriveTurnId` returns null → the
|
|
123
|
+
// dead-air pre-turn card's identity-based adoption never matches (the card
|
|
124
|
+
// is orphaned + a false "handback never started" reap message fires on
|
|
125
|
+
// every SUCCESSFUL handback). Mirrors resume-inbound-builder.ts's
|
|
126
|
+
// `message_id: String(ts)` for the identical enqueue-round-trip reason. It
|
|
127
|
+
// is NEVER used as a Telegram reply anchor: `parseSourceMessageId` gates
|
|
128
|
+
// the 13-digit synthetic ts out of the reply-anchor path at enqueue.
|
|
129
|
+
message_id: String(ts),
|
|
118
130
|
// meta.message_thread_id is the model-visible channel attribute
|
|
119
131
|
// (mirrors the real-inbound shape) so the model's reply targets
|
|
120
132
|
// the dispatching topic. Mirrors gateway.ts:10557.
|
|
@@ -38,6 +38,24 @@ import { join } from "node:path";
|
|
|
38
38
|
|
|
39
39
|
export const TURN_ACTIVE_MARKER_FILE = "turn-active.json";
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Absolute ceiling (ms) beyond which a turn-active signal cannot reflect a
|
|
43
|
+
* real in-flight turn. This is the marker sweep's `hardTtlMs` (`gateway.ts`
|
|
44
|
+
* `sweepStaleTurnActiveMarker` callsite). Exported so the `/model` & `/effort`
|
|
45
|
+
* busy-gate cross-checks the in-memory turn atom and the pending-approval hold
|
|
46
|
+
* against the SAME ceiling it sweeps the marker file at (#3262) — instead of
|
|
47
|
+
* the atom leaking past it and reading as a phantom "active turn" on an idle
|
|
48
|
+
* session.
|
|
49
|
+
*/
|
|
50
|
+
export const TURN_ACTIVE_HARD_TTL_MS = 10 * 60_000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Idle-sweep threshold (ms): the marker is swept this soon when the caller
|
|
54
|
+
* asserts no turn is in flight. Exported alongside the hard TTL so both
|
|
55
|
+
* bounds have a single source of truth.
|
|
56
|
+
*/
|
|
57
|
+
export const TURN_ACTIVE_IDLE_SWEEP_MS = 60_000;
|
|
58
|
+
|
|
41
59
|
export interface TurnActiveMarker {
|
|
42
60
|
turnKey: string;
|
|
43
61
|
chatId: string;
|
|
@@ -196,3 +214,20 @@ export function readTurnActiveMarkerAgeMs(stateDir: string, now?: number): numbe
|
|
|
196
214
|
return null; // ENOENT / unstattable → not working
|
|
197
215
|
}
|
|
198
216
|
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Effective age (ms) of a live turn for the phantom-turn cross-check (#3262):
|
|
220
|
+
* prefer the turn-active liveness marker's mtime age (touched on every
|
|
221
|
+
* tool_use / sub-agent activity, so a genuinely long turn keeps it small),
|
|
222
|
+
* falling back to `now - turnStartedAt` when the marker is absent (e.g. already
|
|
223
|
+
* swept away). Pure so the fallback branch is unit-testable with an injected
|
|
224
|
+
* clock. `markerAgeMs` is the result of `readTurnActiveMarkerAgeMs` (null when
|
|
225
|
+
* the marker is gone).
|
|
226
|
+
*/
|
|
227
|
+
export function effectiveTurnAgeMs(
|
|
228
|
+
markerAgeMs: number | null,
|
|
229
|
+
turnStartedAt: number,
|
|
230
|
+
now: number,
|
|
231
|
+
): number {
|
|
232
|
+
return markerAgeMs ?? now - turnStartedAt;
|
|
233
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Shared CommonMark-correct code-span / fenced-block splitter for the outbound
|
|
2
|
+
// formatting guards (#3252). Every guard that neutralises accidental Telegram
|
|
3
|
+
// markdown typesetting (dollar-math, emphasis, line-start block constructs,
|
|
4
|
+
// inline pairs) must skip content inside code spans and fenced code blocks
|
|
5
|
+
// verbatim. Rather than each guard forking its own copy of the splitter (the
|
|
6
|
+
// dollar guard shipped one, the emphasis + line-start guards each duplicated
|
|
7
|
+
// it), this is the ONE source of truth they all import.
|
|
8
|
+
|
|
9
|
+
/** A contiguous slice of rendered markdown, tagged as code (verbatim, never
|
|
10
|
+
* transformed) or prose (eligible for a guard's rewrite). */
|
|
11
|
+
export interface Segment {
|
|
12
|
+
code: boolean;
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** From index `from` (just past an opening run of `runLen` backticks), find the
|
|
17
|
+
* index immediately AFTER the matching closing run of exactly `runLen`
|
|
18
|
+
* backticks. Returns -1 when there is no matching close (a stray backtick), in
|
|
19
|
+
* which case the opener is treated as literal prose. Equal-length matching is
|
|
20
|
+
* the CommonMark rule for code spans and matches the balanced fences the
|
|
21
|
+
* renderer emits for code blocks. */
|
|
22
|
+
export function findClosingBackticks(text: string, from: number, runLen: number): number {
|
|
23
|
+
let i = from;
|
|
24
|
+
while (i < text.length) {
|
|
25
|
+
if (text[i] === "`") {
|
|
26
|
+
let j = i;
|
|
27
|
+
while (j < text.length && text[j] === "`") j++;
|
|
28
|
+
if (j - i === runLen) return j;
|
|
29
|
+
i = j;
|
|
30
|
+
} else {
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Split rendered markdown into alternating prose / code segments so a guard can
|
|
38
|
+
* skip code spans and fenced code blocks entirely. */
|
|
39
|
+
export function splitCodeSegments(text: string): Segment[] {
|
|
40
|
+
const out: Segment[] = [];
|
|
41
|
+
let i = 0;
|
|
42
|
+
let plainStart = 0;
|
|
43
|
+
while (i < text.length) {
|
|
44
|
+
if (text[i] === "`") {
|
|
45
|
+
let j = i;
|
|
46
|
+
while (j < text.length && text[j] === "`") j++;
|
|
47
|
+
const runLen = j - i;
|
|
48
|
+
const close = findClosingBackticks(text, j, runLen);
|
|
49
|
+
if (close !== -1) {
|
|
50
|
+
if (plainStart < i) out.push({ code: false, text: text.slice(plainStart, i) });
|
|
51
|
+
out.push({ code: true, text: text.slice(i, close) });
|
|
52
|
+
i = close;
|
|
53
|
+
plainStart = close;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
if (plainStart < text.length) out.push({ code: false, text: text.slice(plainStart) });
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Link / autolink / table protection (#3252 — link-aware guards)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
//
|
|
67
|
+
// The four accidental-formatting guards must NOT escape inside content where
|
|
68
|
+
// the trigger chars (`_ * ~ = | $`) are STRUCTURAL rather than prose:
|
|
69
|
+
// • markdown link destinations — the `(url)` (and `(url "title")`) target of
|
|
70
|
+
// a `[label](url)` link. Underscores / tildes / `==` in a URL path or query
|
|
71
|
+
// are part of the address; a backslash there can 404 the link. The link
|
|
72
|
+
// LABEL (`[...]`) is rendered prose and stays guarded.
|
|
73
|
+
// • bare autolinked URLs — `http(s)://…` and `www.…` runs Telegram auto-links.
|
|
74
|
+
// • GFM table rows — a table's structural pipes / empty cells (`|a||b|`) must
|
|
75
|
+
// survive; escaping inside a real table row corrupts the table.
|
|
76
|
+
// Everything else is prose and stays fully guarded. This is the sibling of the
|
|
77
|
+
// code-span skip: a PROTECTED segment (`code: true`) is emitted verbatim.
|
|
78
|
+
//
|
|
79
|
+
// Deterministic and linear-time: one left-to-right char scan per prose chunk
|
|
80
|
+
// plus one line pre-pass for tables; no backtracking regexes.
|
|
81
|
+
|
|
82
|
+
/** A GFM table delimiter row: only pipes / colons / dashes / spaces, with at
|
|
83
|
+
* least one dash AND at least one pipe (a bare `---` is a thematic break, not a
|
|
84
|
+
* table). This is what distinguishes a real table from prose that merely starts
|
|
85
|
+
* with `|`. */
|
|
86
|
+
function isTableDelimiterRow(line: string): boolean {
|
|
87
|
+
const t = line.trim();
|
|
88
|
+
return t.length > 0 && /^[\s|:-]+$/.test(t) && t.includes("-") && t.includes("|");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A candidate table line: after optional leading spaces it begins with `|`. */
|
|
92
|
+
function isTableCandidateLine(line: string): boolean {
|
|
93
|
+
return /^\s*\|/.test(line);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Find the [start, end) char ranges (relative to `text`) of GFM table blocks —
|
|
97
|
+
* maximal runs of 2+ consecutive `|`-leading lines that contain a delimiter
|
|
98
|
+
* row. Each returned range spans whole lines INCLUDING their trailing newline,
|
|
99
|
+
* so a table becomes ONE protected segment. A lone `|a||b|` line with no
|
|
100
|
+
* delimiter row is NOT a real table → not protected → still guarded. */
|
|
101
|
+
function findTableRanges(text: string): Array<[number, number]> {
|
|
102
|
+
const ranges: Array<[number, number]> = [];
|
|
103
|
+
const lines = text.split("\n");
|
|
104
|
+
let offset = 0;
|
|
105
|
+
let runStart = -1;
|
|
106
|
+
let runEnd = -1;
|
|
107
|
+
let runHasDelim = false;
|
|
108
|
+
let runLineCount = 0;
|
|
109
|
+
const flush = () => {
|
|
110
|
+
if (runStart !== -1 && runLineCount >= 2 && runHasDelim) {
|
|
111
|
+
ranges.push([runStart, runEnd]);
|
|
112
|
+
}
|
|
113
|
+
runStart = -1;
|
|
114
|
+
runEnd = -1;
|
|
115
|
+
runHasDelim = false;
|
|
116
|
+
runLineCount = 0;
|
|
117
|
+
};
|
|
118
|
+
for (let k = 0; k < lines.length; k++) {
|
|
119
|
+
const line = lines[k];
|
|
120
|
+
// char length of this line including its trailing newline (except the last).
|
|
121
|
+
const lineLen = line.length + (k < lines.length - 1 ? 1 : 0);
|
|
122
|
+
if (isTableCandidateLine(line)) {
|
|
123
|
+
if (runStart === -1) runStart = offset;
|
|
124
|
+
runEnd = offset + lineLen;
|
|
125
|
+
runLineCount += 1;
|
|
126
|
+
if (isTableDelimiterRow(line)) runHasDelim = true;
|
|
127
|
+
} else {
|
|
128
|
+
flush();
|
|
129
|
+
}
|
|
130
|
+
offset += lineLen;
|
|
131
|
+
}
|
|
132
|
+
flush();
|
|
133
|
+
return ranges;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Protect links / autolinks / table rows within a single PROSE chunk, returning
|
|
137
|
+
* alternating prose / protected segments. */
|
|
138
|
+
function splitProseProtected(text: string): Segment[] {
|
|
139
|
+
const out: Segment[] = [];
|
|
140
|
+
const tables = findTableRanges(text);
|
|
141
|
+
let tIdx = 0;
|
|
142
|
+
let i = 0;
|
|
143
|
+
let plainStart = 0;
|
|
144
|
+
const pushProtected = (from: number, to: number) => {
|
|
145
|
+
if (plainStart < from) out.push({ code: false, text: text.slice(plainStart, from) });
|
|
146
|
+
out.push({ code: true, text: text.slice(from, to) });
|
|
147
|
+
plainStart = to;
|
|
148
|
+
};
|
|
149
|
+
while (i < text.length) {
|
|
150
|
+
// Advance the table pointer past any range we've already scanned past.
|
|
151
|
+
while (tIdx < tables.length && tables[tIdx][1] <= i) tIdx++;
|
|
152
|
+
// 1. Table block — protect whole lines verbatim.
|
|
153
|
+
if (tIdx < tables.length && tables[tIdx][0] === i) {
|
|
154
|
+
const [, end] = tables[tIdx];
|
|
155
|
+
pushProtected(i, end);
|
|
156
|
+
i = end;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const ch = text[i];
|
|
160
|
+
// 2. Markdown link destination: `[label](dest)` — protect only `(dest)`.
|
|
161
|
+
if (ch === "[") {
|
|
162
|
+
const close = text.indexOf("]", i + 1);
|
|
163
|
+
if (close !== -1 && text[close + 1] === "(") {
|
|
164
|
+
const destClose = text.indexOf(")", close + 2);
|
|
165
|
+
if (destClose !== -1) {
|
|
166
|
+
// Protect the `(dest)` span; the `[label]` before it stays prose.
|
|
167
|
+
pushProtected(close + 1, destClose + 1);
|
|
168
|
+
i = destClose + 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
i++;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
// 3. Bare autolink: `http(s)://…` or `www.…`, left-flanked by a boundary.
|
|
176
|
+
if (
|
|
177
|
+
(ch === "h" || ch === "w") &&
|
|
178
|
+
(i === 0 || !/[A-Za-z0-9]/.test(text[i - 1]))
|
|
179
|
+
) {
|
|
180
|
+
const m = /^(?:https?:\/\/|www\.)[^\s<>()\[\]]+/.exec(text.slice(i));
|
|
181
|
+
if (m) {
|
|
182
|
+
const end = i + m[0].length;
|
|
183
|
+
pushProtected(i, end);
|
|
184
|
+
i = end;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
i++;
|
|
189
|
+
}
|
|
190
|
+
if (plainStart < text.length) out.push({ code: false, text: text.slice(plainStart) });
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Split rendered markdown into prose / protected segments where a PROTECTED
|
|
195
|
+
* (`code: true`) segment is any content a guard must emit verbatim: code spans,
|
|
196
|
+
* fenced code blocks, markdown link destinations, bare autolinks, and GFM
|
|
197
|
+
* table rows. This is the link/table-aware superset of `splitCodeSegments` that
|
|
198
|
+
* all four #3252 guards route through. Prose segments (`code: false`) remain
|
|
199
|
+
* guardable (including a link's `[label]` text). Deterministic, linear-time. */
|
|
200
|
+
export function splitProtectedSegments(text: string): Segment[] {
|
|
201
|
+
const out: Segment[] = [];
|
|
202
|
+
for (const seg of splitCodeSegments(text)) {
|
|
203
|
+
if (seg.code) {
|
|
204
|
+
out.push(seg);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
for (const sub of splitProseProtected(seg.text)) out.push(sub);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Outbound guard against accidental TeX/inline-math typesetting of currency
|
|
2
|
+
// amounts (issue #3252).
|
|
3
|
+
//
|
|
4
|
+
// ── Root cause ───────────────────────────────────────────────────────────
|
|
5
|
+
// Since the Bot API 10.1 migration (#2669) every assistant reply is sent to
|
|
6
|
+
// Telegram as RAW GFM markdown via `sendRichMessage({ markdown })` — Telegram
|
|
7
|
+
// parses the markdown server-side. Telegram's rich GFM parser honours the GFM
|
|
8
|
+
// inline-math extension: a `$...$` PAIR typesets the text between the two
|
|
9
|
+
// dollar signs as math (math-italic, U+1D400-range glyphs on the reader's
|
|
10
|
+
// screen). switchroom's renderer (`render.ts` → `escapeMarkdown`) escapes the
|
|
11
|
+
// other inline-formatting triggers (`` ` `` `*` `_` `~` `=` `[` `]` `|`) but
|
|
12
|
+
// NOT `$`. So a perfectly ordinary reply with TWO dollar amounts —
|
|
13
|
+
// "…ceiling is ~$0.5-0.9M but nearly all in small dealers … is ~$150-450k"
|
|
14
|
+
// — accidentally forms a math span across everything between the two `$`, and
|
|
15
|
+
// the reader sees the middle typeset as math-italic. history.db stores the
|
|
16
|
+
// clean ASCII; switchroom emits clean ASCII; the math is produced downstream
|
|
17
|
+
// by Telegram's rich-markdown parser from the unescaped `$…$` pair.
|
|
18
|
+
//
|
|
19
|
+
// ── Fix ──────────────────────────────────────────────────────────────────
|
|
20
|
+
// Deterministically break the `$…$` pairing on the wire by backslash-escaping
|
|
21
|
+
// EVERY prose `$` (`$` → `\$`) once the message looks like it carries currency
|
|
22
|
+
// — i.e. the prose (non-code) content holds 2+ total `$` AND at least one of
|
|
23
|
+
// them sits next to a digit (`$50`, `$.50`, or a trailing `50$`). A lone `$`
|
|
24
|
+
// (single total, or no digit-adjacent `$` anywhere) can never form a currency
|
|
25
|
+
// math span the way #3252 describes, so it is left byte-for-byte untouched.
|
|
26
|
+
// Code spans / fenced code blocks are NEVER touched (verbatim). Escaping every
|
|
27
|
+
// prose `$` (not just the `$digit` ones) is what closes the F3 false-negatives:
|
|
28
|
+
// `"$50 or 50$"`, `"$10 … from $ yesterday"`, and `"$.50 and $5"` all still form
|
|
29
|
+
// a `$…$` pair if only the leading-`$digit` token is escaped — so we escape the
|
|
30
|
+
// lot once the currency signal + 2-dollar threshold are met.
|
|
31
|
+
//
|
|
32
|
+
// Idempotent (F5): the escape uses a negative-lookbehind (`(?<!\\)\$`) so an
|
|
33
|
+
// already-escaped `\$` is never doubled to `\\$`. Running the guard twice (e.g.
|
|
34
|
+
// the streaming path renders then this wrapper re-wraps) is a strict no-op the
|
|
35
|
+
// second time.
|
|
36
|
+
//
|
|
37
|
+
// ── Where this runs (F1) ───────────────────────────────────────────────────
|
|
38
|
+
// This guard is invoked from `richMessage()` (rich-send.ts) — the ONE adapter
|
|
39
|
+
// every `{ markdown }` wire send funnels through (the `reply`-tool final answer
|
|
40
|
+
// via computeReplyChunks/sendReplyChunks, the draft-stream previews, cards,
|
|
41
|
+
// approvals, banners). That is the single deterministic seam that covers every
|
|
42
|
+
// markdown-parsed outbound exactly once. `plain`-mode degradations bypass
|
|
43
|
+
// `richMessage` (they go straight to `sendMessage`, no markdown parsing → no
|
|
44
|
+
// math), so they are correctly left untouched.
|
|
45
|
+
//
|
|
46
|
+
// Why `\$` and not a zero-width joiner (U+2060):
|
|
47
|
+
// - It reuses the EXACT mechanism switchroom already relies on for every
|
|
48
|
+
// other formatting char (`escapeMarkdown` backslash-escapes `~ = | * _`
|
|
49
|
+
// etc.), which Telegram's rich parser demonstrably strips and renders
|
|
50
|
+
// literally — the whole card system depends on it. `$` is ASCII
|
|
51
|
+
// punctuation, so CommonMark's "any ASCII punctuation may be
|
|
52
|
+
// backslash-escaped" rule applies identically.
|
|
53
|
+
// - The reader sees a literal `$`; copy-paste yields exactly `$0.5-0.9M`
|
|
54
|
+
// (the backslash is consumed by the parser, never in the rendered text or
|
|
55
|
+
// the clipboard). No zero-width characters that could pollute search /
|
|
56
|
+
// copy / re-tokenisation.
|
|
57
|
+
// - Fully deterministic (pure string transform), and a strict no-op for any
|
|
58
|
+
// message with fewer than two `$digit` tokens.
|
|
59
|
+
//
|
|
60
|
+
// Tradeoff (UNVERIFIED — see F4): this relies on Telegram's server-side rich
|
|
61
|
+
// GFM parser (a) recognising `$` as an escapable ASCII punctuation char and
|
|
62
|
+
// (b) CONSUMING the backslash so the reader sees a literal `$` and copy-paste
|
|
63
|
+
// yields `$0.5-0.9M` (no stray `\`). This is asserted by analogy to the
|
|
64
|
+
// `` ~ = | * _ `` chars that `escapeMarkdown` (format.ts) already backslash-
|
|
65
|
+
// escapes and that Telegram demonstrably strips — BUT note `escapeMarkdown`
|
|
66
|
+
// pointedly does NOT include `$` in its escape set, so we have no in-repo
|
|
67
|
+
// evidence that `\$` specifically round-trips. Vitest CANNOT cover this (it is
|
|
68
|
+
// server-side Telegram behaviour). If Telegram does NOT consume the backslash,
|
|
69
|
+
// guarded replies would show a visible `\$0.5-0.9M` — arguably worse than the
|
|
70
|
+
// math bug. **This is the one residual risk requiring human UAT before merge:**
|
|
71
|
+
// send a real two-dollar-amount message through a live agent and confirm it
|
|
72
|
+
// renders as a literal `$` (not `\$`, not math-italic). See the PR's UAT note.
|
|
73
|
+
|
|
74
|
+
// The code-span/fence splitter now lives in ONE shared module so every #3252
|
|
75
|
+
// guard reuses the same CommonMark-correct implementation. Re-exported here for
|
|
76
|
+
// backwards compatibility with any importer that reached for it via this file.
|
|
77
|
+
import { splitCodeSegments, splitProtectedSegments, type Segment } from "./code-segments.js";
|
|
78
|
+
export { splitCodeSegments, splitProtectedSegments, type Segment };
|
|
79
|
+
|
|
80
|
+
/** Counts every `$` in a segment (the total-dollar threshold input). */
|
|
81
|
+
const ANY_DOLLAR = /\$/g;
|
|
82
|
+
|
|
83
|
+
/** The "this is currency, not a stray symbol" signal: a `$` sitting next to a
|
|
84
|
+
* digit on either side — `$50` / `$.50` (leading) or `50$` (trailing), the
|
|
85
|
+
* latter covering European / trailing-symbol conventions. One such token in
|
|
86
|
+
* the prose is enough to arm the guard (combined with the 2+ total threshold).
|
|
87
|
+
* Non-global: used only as a boolean `.test`. */
|
|
88
|
+
const CURRENCY_SIGNAL = /\$\.?\d|\d\s?\$/;
|
|
89
|
+
|
|
90
|
+
/** Every prose `$` that is NOT already backslash-escaped. The negative
|
|
91
|
+
* lookbehind makes the escape idempotent (F5): a pre-existing `\$` is left
|
|
92
|
+
* alone, so running the guard twice never produces `\\$`. Global for replace. */
|
|
93
|
+
const UNESCAPED_DOLLAR = /(?<!\\)\$/g;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Neutralise accidental `$…$` inline-math typesetting of currency amounts.
|
|
97
|
+
*
|
|
98
|
+
* Operates on the FINAL rendered rich-markdown string (post-`render`). A no-op
|
|
99
|
+
* unless the prose (non-code) content holds 2+ total `$` AND at least one of
|
|
100
|
+
* them is digit-adjacent (a currency signal). When armed, EVERY unescaped prose
|
|
101
|
+
* `$` is backslash-escaped so no two `$` can pair into a math span — this is
|
|
102
|
+
* what closes the F3 trailing-`$` / `$.50` false-negatives. Code spans / fenced
|
|
103
|
+
* blocks are never touched. Idempotent (F5) and deterministic.
|
|
104
|
+
*/
|
|
105
|
+
export function guardDollarMath(text: string): string {
|
|
106
|
+
if (!text.includes("$")) return text;
|
|
107
|
+
// Link-aware: a `$` inside a URL/table (rare but possible) is structural and
|
|
108
|
+
// must not be escaped. splitProtectedSegments skips code/links/autolinks/tables.
|
|
109
|
+
const segments = splitProtectedSegments(text);
|
|
110
|
+
|
|
111
|
+
let total = 0;
|
|
112
|
+
let hasCurrencySignal = false;
|
|
113
|
+
for (const seg of segments) {
|
|
114
|
+
if (seg.code) continue;
|
|
115
|
+
total += seg.text.match(ANY_DOLLAR)?.length ?? 0;
|
|
116
|
+
if (!hasCurrencySignal && CURRENCY_SIGNAL.test(seg.text)) hasCurrencySignal = true;
|
|
117
|
+
}
|
|
118
|
+
// Fewer than two dollars can never form a pair; without a digit-adjacent `$`
|
|
119
|
+
// the two dollars are (e.g.) `$foo`/`$bar` shell-var prose, which we leave
|
|
120
|
+
// alone to avoid escaping legitimate lone symbols (F3: no false positives).
|
|
121
|
+
if (total < 2 || !hasCurrencySignal) return text;
|
|
122
|
+
|
|
123
|
+
return segments
|
|
124
|
+
.map((seg) => (seg.code ? seg.text : seg.text.replace(UNESCAPED_DOLLAR, () => "\\$")))
|
|
125
|
+
.join("");
|
|
126
|
+
}
|