switchroom 0.18.24 → 0.18.26
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 +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1827 -831
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +314 -3
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- 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/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- 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/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +104 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
|
@@ -120,6 +120,26 @@ export interface Turn {
|
|
|
120
120
|
* Null for turns that were never resumed.
|
|
121
121
|
*/
|
|
122
122
|
resumed_at: number | null
|
|
123
|
+
/**
|
|
124
|
+
* The claude session id (the `<sessionId>.jsonl` transcript stem) that
|
|
125
|
+
* produced this turn's assistant output, stamped DURING the turn as soon as
|
|
126
|
+
* the first session event is observed (see `stampTurnSessionId`). Crash-
|
|
127
|
+
* survival redelivery uses this to resolve the EXACT transcript file for an
|
|
128
|
+
* interrupted turn, instead of `findActiveSessionFile`'s most-recent-mtime
|
|
129
|
+
* heuristic — which can shadow the new boot session's own transcript. Null
|
|
130
|
+
* until the turn produces its first session event (or for pre-migration rows).
|
|
131
|
+
*/
|
|
132
|
+
session_id: string | null
|
|
133
|
+
/**
|
|
134
|
+
* Ms epoch at which the interrupted turn's captured-but-undelivered final
|
|
135
|
+
* answer was re-sent to the user at boot (crash-survival redelivery). This is
|
|
136
|
+
* the at-most-once ledger for redelivery — first-write-wins via
|
|
137
|
+
* `WHERE answer_redelivered_at IS NULL` (see `markAnswerRedelivered`). Kept on
|
|
138
|
+
* a SEPARATE marker from `resumed_at` because the two concerns have different
|
|
139
|
+
* correctness contracts (resume = at-most-once side-effect replay; redelivery
|
|
140
|
+
* = at-most-once answer send) and a turn can be both. Null until redelivered.
|
|
141
|
+
*/
|
|
142
|
+
answer_redelivered_at: number | null
|
|
123
143
|
created_at: number
|
|
124
144
|
updated_at: number
|
|
125
145
|
}
|
|
@@ -190,6 +210,15 @@ const PHASE3_MIGRATIONS = [
|
|
|
190
210
|
`ALTER TABLE turns ADD COLUMN resumed_at INTEGER`,
|
|
191
211
|
]
|
|
192
212
|
|
|
213
|
+
// Columns added for crash-survival redelivery. `session_id` pins the exact
|
|
214
|
+
// transcript file for an interrupted turn (so redelivery never resolves the
|
|
215
|
+
// wrong session via a most-recent-file heuristic); `answer_redelivered_at` is
|
|
216
|
+
// the at-most-once redelivery ledger (stamped synchronously with the re-send).
|
|
217
|
+
const PHASE4_MIGRATIONS = [
|
|
218
|
+
`ALTER TABLE turns ADD COLUMN session_id TEXT`,
|
|
219
|
+
`ALTER TABLE turns ADD COLUMN answer_redelivered_at INTEGER`,
|
|
220
|
+
]
|
|
221
|
+
|
|
193
222
|
function applySchema(db: SqliteDatabase): void {
|
|
194
223
|
db.exec('PRAGMA journal_mode = WAL')
|
|
195
224
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
@@ -206,7 +235,7 @@ function applySchema(db: SqliteDatabase): void {
|
|
|
206
235
|
// Run migrations. SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so
|
|
207
236
|
// we swallow the "duplicate column" error to stay idempotent on
|
|
208
237
|
// pre-existing registry.db files.
|
|
209
|
-
for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS, ...PHASE3_MIGRATIONS]) {
|
|
238
|
+
for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS, ...PHASE3_MIGRATIONS, ...PHASE4_MIGRATIONS]) {
|
|
210
239
|
try {
|
|
211
240
|
db.exec(sql)
|
|
212
241
|
} catch (err) {
|
|
@@ -283,6 +312,8 @@ interface RawTurnRow {
|
|
|
283
312
|
tool_call_count: number | null
|
|
284
313
|
interrupt_reason: string | null
|
|
285
314
|
resumed_at: number | null
|
|
315
|
+
session_id: string | null
|
|
316
|
+
answer_redelivered_at: number | null
|
|
286
317
|
created_at: number
|
|
287
318
|
updated_at: number
|
|
288
319
|
}
|
|
@@ -304,6 +335,8 @@ function mapRow(row: RawTurnRow): Turn {
|
|
|
304
335
|
tool_call_count: row.tool_call_count,
|
|
305
336
|
interrupt_reason: row.interrupt_reason,
|
|
306
337
|
resumed_at: row.resumed_at,
|
|
338
|
+
session_id: row.session_id ?? null,
|
|
339
|
+
answer_redelivered_at: row.answer_redelivered_at ?? null,
|
|
307
340
|
created_at: row.created_at,
|
|
308
341
|
updated_at: row.updated_at,
|
|
309
342
|
}
|
|
@@ -692,6 +725,61 @@ export function markTurnResumed(
|
|
|
692
725
|
`).run(now, now, turnKey)
|
|
693
726
|
}
|
|
694
727
|
|
|
728
|
+
/**
|
|
729
|
+
* Stamp the claude `session_id` (the `<sessionId>.jsonl` transcript stem) on a
|
|
730
|
+
* turn the FIRST time it is observed, DURING the turn. First-write-wins via
|
|
731
|
+
* `WHERE session_id IS NULL` so the hot session-event path can call this on
|
|
732
|
+
* every event cheaply and idempotently. This must run while the turn is live
|
|
733
|
+
* (before any crash) so crash-survival redelivery can resolve the exact
|
|
734
|
+
* transcript file for an interrupted turn — never the most-recent-mtime file,
|
|
735
|
+
* which a fresh boot session would shadow. No-ops if `turnKey` is not found.
|
|
736
|
+
*/
|
|
737
|
+
export function stampTurnSessionId(
|
|
738
|
+
db: SqliteDatabase,
|
|
739
|
+
turnKey: string,
|
|
740
|
+
sessionId: string,
|
|
741
|
+
now: number = Date.now(),
|
|
742
|
+
): void {
|
|
743
|
+
if (!sessionId) return
|
|
744
|
+
db.prepare(`
|
|
745
|
+
UPDATE turns
|
|
746
|
+
SET session_id = ?,
|
|
747
|
+
updated_at = ?
|
|
748
|
+
WHERE turn_key = ? AND session_id IS NULL
|
|
749
|
+
`).run(sessionId, now, turnKey)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Stamp `answer_redelivered_at` on an interrupted turn at the moment its
|
|
754
|
+
* captured-but-undelivered final answer has been re-sent at boot (crash-
|
|
755
|
+
* survival redelivery). This is the at-most-once ledger for redelivery: once
|
|
756
|
+
* stamped, the redelivery decision skips the turn on any later restart.
|
|
757
|
+
*
|
|
758
|
+
* Ordering: the caller stamps SYNCHRONOUSLY with the send (immediately after
|
|
759
|
+
* the send resolves), the same discipline as `markTurnResumed`. A residual
|
|
760
|
+
* race remains — the window between the Telegram send completing and this row
|
|
761
|
+
* (plus the send's own `role='assistant'` history row) becoming durable. A
|
|
762
|
+
* crash landing in that window could re-send on the next boot; the durable
|
|
763
|
+
* text-identity delivery oracle (matching the projected answer text against
|
|
764
|
+
* delivered `messages` rows) is what CATCHES that duplicate, so redelivery is
|
|
765
|
+
* at-most-once modulo detection, never a silent double-send of a fresh answer.
|
|
766
|
+
*
|
|
767
|
+
* Idempotent and first-write-wins (`WHERE answer_redelivered_at IS NULL`).
|
|
768
|
+
* No-ops if `turnKey` is not found.
|
|
769
|
+
*/
|
|
770
|
+
export function markAnswerRedelivered(
|
|
771
|
+
db: SqliteDatabase,
|
|
772
|
+
turnKey: string,
|
|
773
|
+
now: number = Date.now(),
|
|
774
|
+
): void {
|
|
775
|
+
db.prepare(`
|
|
776
|
+
UPDATE turns
|
|
777
|
+
SET answer_redelivered_at = ?,
|
|
778
|
+
updated_at = ?
|
|
779
|
+
WHERE turn_key = ? AND answer_redelivered_at IS NULL
|
|
780
|
+
`).run(now, now, turnKey)
|
|
781
|
+
}
|
|
782
|
+
|
|
695
783
|
/**
|
|
696
784
|
* Return the single most-recently-started turn IFF it was interrupted
|
|
697
785
|
* (`ended_at IS NULL`, or `ended_via` in {restart, sigterm, timeout,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Outbound guard against accidental inline-emphasis typesetting from `_`/`*`
|
|
2
|
+
// that the model never meant as formatting (issue #3252, sibling of the
|
|
3
|
+
// `$…$` currency-math guard in dollar-math-guard.ts).
|
|
4
|
+
//
|
|
5
|
+
// ── Root cause ───────────────────────────────────────────────────────────
|
|
6
|
+
// Since the Bot API 10.1 migration (#2669) every assistant reply is sent to
|
|
7
|
+
// Telegram as RAW GFM markdown via `sendRichMessage({ markdown })` — Telegram
|
|
8
|
+
// parses the markdown server-side. Telegram's rich GFM parser pairs `_`/`*`
|
|
9
|
+
// delimiters into emphasis spans. The agent DELIBERATELY uses `**bold**`,
|
|
10
|
+
// `*italic*` and `_italic_` (Ken's rich-formatting directive) — that is a
|
|
11
|
+
// wanted feature and MUST survive untouched. But the same characters also
|
|
12
|
+
// occur in prose the model never meant to format:
|
|
13
|
+
// - snake_case identifiers — `file_name_here` italicises "name" once two
|
|
14
|
+
// intra-word `_` pair up (and Telegram's `_` pairing is more liberal than
|
|
15
|
+
// strict CommonMark, which does not emphasise intra-word underscores);
|
|
16
|
+
// - inline multiplication — `a*b*c`, `2*3` — where two intra-word `*` pair
|
|
17
|
+
// and italicise the middle factor;
|
|
18
|
+
// - mid-word `_`/`*` in paths / variable names.
|
|
19
|
+
// These are the direct sibling of the dollar bug: a delimiter that pairs up
|
|
20
|
+
// across prose and typesets a span nobody meant.
|
|
21
|
+
//
|
|
22
|
+
// ── Fix ──────────────────────────────────────────────────────────────────
|
|
23
|
+
// Deterministically break the pairing by backslash-escaping ONLY the clearly-
|
|
24
|
+
// accidental delimiters, identified by a single unambiguous signal:
|
|
25
|
+
//
|
|
26
|
+
// an INTRA-WORD delimiter — a single `_` or `*` with an ASCII alphanumeric
|
|
27
|
+
// character IMMEDIATELY on BOTH sides (`e_n`, `a*b`, `2_f`, `2*3`).
|
|
28
|
+
//
|
|
29
|
+
// This signal is chosen because intended emphasis can NEVER match it: an
|
|
30
|
+
// intended opener (`*italic*`, `_italic_`) is flanked on its OUTER side by a
|
|
31
|
+
// word boundary — whitespace, start-of-string, or punctuation — never by an
|
|
32
|
+
// alphanumeric. So an alphanumeric-on-both-sides delimiter is, by
|
|
33
|
+
// construction, not the opener or closer of an intended span. `**bold**` /
|
|
34
|
+
// `__x__` double runs are excluded for free: the inner neighbour of each `*`
|
|
35
|
+
// in `a**b` is another `*` (not alphanumeric), so neither half matches.
|
|
36
|
+
//
|
|
37
|
+
// What we DELIBERATELY LEAVE ALONE (conservative false-negatives, per the
|
|
38
|
+
// "when in doubt, leave it" doctrine inherited from the dollar guard):
|
|
39
|
+
// - Space-flanked operators (`3 * 4`): a whitespace-flanked `*`/`_` is
|
|
40
|
+
// neither left- nor right-flanking under GFM, so it can never open or
|
|
41
|
+
// close emphasis — there is no bug to fix, and escaping it would be pure
|
|
42
|
+
// churn.
|
|
43
|
+
// - Boundary-flanked delimiters (`rm *`, `*.ts`, leading-`_` `_private`):
|
|
44
|
+
// these are INDISTINGUISHABLE from an intended `*glob*` / `_italic_`
|
|
45
|
+
// opener (`*.ts is a glob*` is a legitimate italic). Escaping them would
|
|
46
|
+
// risk breaking intended emphasis — the one thing this guard must never
|
|
47
|
+
// do — so a glob/leading-underscore that pairs into an accidental span is
|
|
48
|
+
// accepted as a rare false-negative rather than risked as a false-positive.
|
|
49
|
+
// (A future arm could target these behind live Telegram UAT; see below.)
|
|
50
|
+
//
|
|
51
|
+
// Idempotent: the escape is expressed as an intra-word match, so an
|
|
52
|
+
// already-escaped `\_`/`\*` has a backslash (not an alphanumeric) immediately
|
|
53
|
+
// before the delimiter and therefore never re-matches — running the guard
|
|
54
|
+
// twice is a strict no-op. Code spans / fenced code blocks are NEVER touched
|
|
55
|
+
// (verbatim), via the same splitCodeSegments logic the dollar guard uses.
|
|
56
|
+
//
|
|
57
|
+
// ── Where this runs ────────────────────────────────────────────────────────
|
|
58
|
+
// Like the dollar guard, this is intended to be invoked from `richMessage()`
|
|
59
|
+
// (rich-send.ts) — the single seam every `{ markdown }` wire send funnels
|
|
60
|
+
// through. THIS commit only adds the pure function + its test; the seam
|
|
61
|
+
// wiring is integrated separately (all guards in one pass) to avoid conflicts.
|
|
62
|
+
//
|
|
63
|
+
// ── UNVERIFIED (needs live Telegram UAT) ───────────────────────────────────
|
|
64
|
+
// Same residual risk as the dollar guard: this relies on Telegram's server-
|
|
65
|
+
// side rich GFM parser (a) recognising `\_` / `\*` as escapable ASCII
|
|
66
|
+
// punctuation and (b) CONSUMING the backslash so the reader sees a literal
|
|
67
|
+
// `_` / `*` and copy-paste yields `file_name` / `a*b` (no stray `\`). This is
|
|
68
|
+
// asserted by analogy to the `` ~ = | * _ `` chars `escapeMarkdown`
|
|
69
|
+
// (format.ts) already backslash-escapes and that Telegram demonstrably strips.
|
|
70
|
+
// Vitest CANNOT cover server-side rendering; a real intra-word `_`/`*` message
|
|
71
|
+
// through a live agent must confirm it renders literal (not `\_`, not italic).
|
|
72
|
+
|
|
73
|
+
// Segment splitting (code spans / fences AND markdown link destinations /
|
|
74
|
+
// autolinks / table rows) is shared across all #3252 guards — one source of
|
|
75
|
+
// truth in render/code-segments.ts. Routing through `splitProtectedSegments`
|
|
76
|
+
// (not the code-only `splitCodeSegments`) is what makes this guard link-aware:
|
|
77
|
+
// an intra-word `_`/`*` inside a URL path (`.../a_b_c`) is STRUCTURAL, not
|
|
78
|
+
// prose, and must never be escaped (the link would break). See code-segments.ts.
|
|
79
|
+
import { splitProtectedSegments } from "./code-segments.js";
|
|
80
|
+
|
|
81
|
+
/** An intra-word underscore: a single `_` with an ASCII alphanumeric on BOTH
|
|
82
|
+
* immediate sides (`file_name`, `v2_final`). Intended `_italic_` never
|
|
83
|
+
* matches — its opener is boundary-flanked on the outer side. The
|
|
84
|
+
* alphanumeric lookbehind also makes the escape idempotent: an already-
|
|
85
|
+
* escaped `\_` has `\` (not alnum) before the `_`, so it never re-matches. */
|
|
86
|
+
const INTRA_WORD_UNDERSCORE = /(?<=[A-Za-z0-9])_(?=[A-Za-z0-9])/g;
|
|
87
|
+
|
|
88
|
+
/** An intra-word asterisk: a single `*` with an ASCII alphanumeric on BOTH
|
|
89
|
+
* immediate sides (`a*b`, `2*3`). Intended `*italic*` / `**bold**` never
|
|
90
|
+
* match — italic openers are boundary-flanked, and in a `**` run the inner
|
|
91
|
+
* neighbour is `*` (not alnum). Idempotent for the same reason as above. */
|
|
92
|
+
const INTRA_WORD_ASTERISK = /(?<=[A-Za-z0-9])\*(?=[A-Za-z0-9])/g;
|
|
93
|
+
|
|
94
|
+
/** Every unescaped `_` / `*` in prose (the pair-threshold input). An emphasis
|
|
95
|
+
* span needs a MATCHING pair of the same delimiter, so a delimiter can only
|
|
96
|
+
* mis-render when 2+ of it exist. The `(?<!\\)` keeps the count idempotent. */
|
|
97
|
+
const ANY_UNDERSCORE = /(?<!\\)_/g;
|
|
98
|
+
const ANY_ASTERISK = /(?<!\\)\*/g;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Neutralise accidental inline emphasis produced by intra-word `_` / `*`.
|
|
102
|
+
*
|
|
103
|
+
* Operates on the FINAL rendered rich-markdown string (post-`render`).
|
|
104
|
+
*
|
|
105
|
+
* ── Threshold: 2+ of the delimiter (finding 4) ────────────────────────────
|
|
106
|
+
* A `_` (or `*`) escape only fires when the prose holds an intra-word
|
|
107
|
+
* occurrence of that delimiter AND 2+ TOTAL of it. Emphasis is a PAIRED
|
|
108
|
+
* construct — a single `_` (or `*`) in the whole message can never form a span,
|
|
109
|
+
* so escaping a lone intra-word delimiter is pure churn that only widens the
|
|
110
|
+
* false-positive surface (a stray `\` if Telegram ever failed to consume it),
|
|
111
|
+
* with zero correctness gain. Requiring 2+ matches the dollar / inline-pairs
|
|
112
|
+
* "a lone delimiter can't pair" doctrine. This is strictly SAFE: any real
|
|
113
|
+
* mis-render needs 2+ delimiters, and when 2+ exist (e.g. `file_name_here`, or
|
|
114
|
+
* an intra-word `file_name` sitting alongside an intended `_italic_` elsewhere —
|
|
115
|
+
* three underscores that Telegram could mis-pair) the intra-word delimiter IS
|
|
116
|
+
* escaped, so no accidental span survives. Only the provably-inert lone-`_`
|
|
117
|
+
* (`file_name` as the ONLY underscore in the message) is now left byte-identical.
|
|
118
|
+
*
|
|
119
|
+
* Intended `**bold**`, `*italic*` and `_italic_` are LEFT UNTOUCHED by
|
|
120
|
+
* construction (their delimiters are boundary-flanked, never intra-word).
|
|
121
|
+
* Code spans / fenced blocks, link destinations and autolinks are never touched
|
|
122
|
+
* (via `splitProtectedSegments`). Idempotent and deterministic.
|
|
123
|
+
*/
|
|
124
|
+
export function guardAccidentalEmphasis(text: string): string {
|
|
125
|
+
if (!text.includes("_") && !text.includes("*")) return text;
|
|
126
|
+
const segments = splitProtectedSegments(text);
|
|
127
|
+
|
|
128
|
+
// NO-OP guard: only rewrite when a real intra-word signal exists in prose AND
|
|
129
|
+
// 2+ of that delimiter exist (so a pair — hence a mis-render — is possible).
|
|
130
|
+
let hasIntraUnderscore = false;
|
|
131
|
+
let hasIntraAsterisk = false;
|
|
132
|
+
let underscoreCount = 0;
|
|
133
|
+
let asteriskCount = 0;
|
|
134
|
+
for (const seg of segments) {
|
|
135
|
+
if (seg.code) continue;
|
|
136
|
+
if (INTRA_WORD_UNDERSCORE.test(seg.text)) hasIntraUnderscore = true;
|
|
137
|
+
if (INTRA_WORD_ASTERISK.test(seg.text)) hasIntraAsterisk = true;
|
|
138
|
+
underscoreCount += seg.text.match(ANY_UNDERSCORE)?.length ?? 0;
|
|
139
|
+
asteriskCount += seg.text.match(ANY_ASTERISK)?.length ?? 0;
|
|
140
|
+
}
|
|
141
|
+
// Reset lastIndex — the /g regexes above are stateful across .test() calls.
|
|
142
|
+
INTRA_WORD_UNDERSCORE.lastIndex = 0;
|
|
143
|
+
INTRA_WORD_ASTERISK.lastIndex = 0;
|
|
144
|
+
|
|
145
|
+
const armUnderscore = hasIntraUnderscore && underscoreCount >= 2;
|
|
146
|
+
const armAsterisk = hasIntraAsterisk && asteriskCount >= 2;
|
|
147
|
+
if (!armUnderscore && !armAsterisk) return text;
|
|
148
|
+
|
|
149
|
+
return segments
|
|
150
|
+
.map((seg) => {
|
|
151
|
+
if (seg.code) return seg.text;
|
|
152
|
+
let out = seg.text;
|
|
153
|
+
if (armUnderscore) out = out.replace(INTRA_WORD_UNDERSCORE, "\\_");
|
|
154
|
+
if (armAsterisk) out = out.replace(INTRA_WORD_ASTERISK, "\\*");
|
|
155
|
+
return out;
|
|
156
|
+
})
|
|
157
|
+
.join("");
|
|
158
|
+
}
|