switchroom 0.18.11 → 0.18.13
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 +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import {
|
|
32
32
|
validateInlineKeyboard,
|
|
33
|
+
TELEGRAM_BUTTON_LIMITS,
|
|
33
34
|
type AnyButton,
|
|
34
35
|
type ButtonValidationError,
|
|
35
36
|
} from './telegram-button-constraints.js'
|
|
@@ -60,10 +61,25 @@ export interface AgentButtonMeta {
|
|
|
60
61
|
* removes the entire keyboard to prevent double-fire.
|
|
61
62
|
*/
|
|
62
63
|
single_use?: boolean
|
|
64
|
+
/**
|
|
65
|
+
* Per-message override for the button-choice-confirmation annotation
|
|
66
|
+
* (#789). When true, tapping annotates the source message body with a
|
|
67
|
+
* "✅ You chose: <label> · HH:MM" line even if the agent default is off;
|
|
68
|
+
* when false, annotation is skipped even if the agent default is on.
|
|
69
|
+
* Undefined falls back to the agent's
|
|
70
|
+
* channels.telegram.button_choice_confirmation.enabled default. Only takes
|
|
71
|
+
* effect on single-use keyboards (re-tappable keyboards are never
|
|
72
|
+
* annotated).
|
|
73
|
+
*/
|
|
74
|
+
inline_keyboard_confirm?: boolean
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
/** Fields the gateway adds to button objects — not valid Telegram API fields. */
|
|
66
|
-
const AGENT_META_FIELDS: ReadonlyArray<keyof AgentButtonMeta> = [
|
|
78
|
+
const AGENT_META_FIELDS: ReadonlyArray<keyof AgentButtonMeta> = [
|
|
79
|
+
'ack_text',
|
|
80
|
+
'single_use',
|
|
81
|
+
'inline_keyboard_confirm',
|
|
82
|
+
]
|
|
67
83
|
|
|
68
84
|
/**
|
|
69
85
|
* Wrap every callback_data field in a 2D inline-keyboard with the
|
|
@@ -99,6 +115,99 @@ export function wrapAgentCallbacks(keyboard: AnyButton[][]): AnyButton[][] {
|
|
|
99
115
|
)
|
|
100
116
|
}
|
|
101
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Redact agent-authored free-text on an inline keyboard BEFORE it is sent to
|
|
120
|
+
* Telegram (#3148 fast-follow secret-scrub coverage). `wrapAgentCallbacks`
|
|
121
|
+
* rewrites only `callback_data`; the visible `text` label, the `ack_text`
|
|
122
|
+
* toast, and any `copy_text.text` clipboard payload pass through VERBATIM. An
|
|
123
|
+
* agent that puts a secret in any of those transmits it unmasked, and it
|
|
124
|
+
* resurfaces on tap — the label is echoed back (`button_text`), re-rendered in
|
|
125
|
+
* the "✅ You chose: <label>" annotation (#789), and `ack_text` is shown as the
|
|
126
|
+
* toast. `switch_inline_query` / `switch_inline_query_current_chat` /
|
|
127
|
+
* `switch_inline_query_chosen_chat.query` are also agent-authored free text
|
|
128
|
+
* that Telegram pastes into a chat's input box on tap (the current chat for
|
|
129
|
+
* the `_current_chat` variant, a user-picked chat for `_chosen_chat` — both
|
|
130
|
+
* user-visible), so they carry the same leak class. Route every one of these free-text fields
|
|
131
|
+
* through the SAME outbound redactor the reply `text` body uses, at the
|
|
132
|
+
* outbound boundary, so every downstream resurface reads already-masked bytes.
|
|
133
|
+
*
|
|
134
|
+
* `callback_data` is the routing key and is NEVER touched — redacting it would
|
|
135
|
+
* break tap round-tripping. Button structure/order is preserved; the redactor
|
|
136
|
+
* (`redact()`) only replaces detected secret byte-ranges with a non-empty
|
|
137
|
+
* marker, so it never empties a label (the non-empty-text invariant holds).
|
|
138
|
+
* Returns a fresh keyboard; does not mutate the input. `redactFn` is injected
|
|
139
|
+
* so this stays pure + unit-testable and carries no gateway import cycle.
|
|
140
|
+
*
|
|
141
|
+
* Only agent-authored keyboards flow through here (the `reply` tool path);
|
|
142
|
+
* framework-internal keyboards (approval cards, vault wizard, model menus) are
|
|
143
|
+
* built separately and are NOT redacted by this function.
|
|
144
|
+
*/
|
|
145
|
+
export function redactAgentKeyboard(
|
|
146
|
+
keyboard: AnyButton[][],
|
|
147
|
+
redactFn: (s: string) => string,
|
|
148
|
+
): AnyButton[][] {
|
|
149
|
+
// The keyboard is validated for length BEFORE redaction, but the redaction
|
|
150
|
+
// marker (`[REDACTED:...]`) can be longer than the secret it replaces, so a
|
|
151
|
+
// field that was within a Telegram cap can exceed it after masking. An
|
|
152
|
+
// over-limit button field makes sendMessage 400 → the WHOLE reply is dropped
|
|
153
|
+
// (worse than the leak we just closed), so clamp each masked free-text field
|
|
154
|
+
// to its cap. Truncating a marker is harmless — it's already non-secret.
|
|
155
|
+
const clamp = (s: string, max: number): string =>
|
|
156
|
+
s.length > max ? s.slice(0, max) : s
|
|
157
|
+
return keyboard.map((row) =>
|
|
158
|
+
row.map((btn) => {
|
|
159
|
+
const out: AnyButton = { ...btn }
|
|
160
|
+
if (typeof out.text === 'string') {
|
|
161
|
+
out.text = clamp(redactFn(out.text), TELEGRAM_BUTTON_LIMITS.TEXT_MAX)
|
|
162
|
+
}
|
|
163
|
+
// ack_text is a switchroom-side toast (answerCallbackQuery), not a
|
|
164
|
+
// sendMessage field, so an over-length value can't drop the reply — no
|
|
165
|
+
// clamp needed, but redact it all the same.
|
|
166
|
+
if (typeof out.ack_text === 'string') out.ack_text = redactFn(out.ack_text)
|
|
167
|
+
// switch_inline_query* paste agent free text into a chat input box on tap
|
|
168
|
+
// — same leak class as the label. URL-class fields (url/web_app/login_url)
|
|
169
|
+
// are deliberately left exact: a [REDACTED] marker would corrupt a URL.
|
|
170
|
+
const siq = (out as { switch_inline_query?: unknown }).switch_inline_query
|
|
171
|
+
if (typeof siq === 'string') {
|
|
172
|
+
(out as { switch_inline_query?: string }).switch_inline_query = clamp(
|
|
173
|
+
redactFn(siq), TELEGRAM_BUTTON_LIMITS.SWITCH_INLINE_QUERY_MAX)
|
|
174
|
+
}
|
|
175
|
+
const siqc = (out as { switch_inline_query_current_chat?: unknown })
|
|
176
|
+
.switch_inline_query_current_chat
|
|
177
|
+
if (typeof siqc === 'string') {
|
|
178
|
+
(out as { switch_inline_query_current_chat?: string })
|
|
179
|
+
.switch_inline_query_current_chat = clamp(
|
|
180
|
+
redactFn(siqc), TELEGRAM_BUTTON_LIMITS.SWITCH_INLINE_QUERY_MAX)
|
|
181
|
+
}
|
|
182
|
+
// switch_inline_query_chosen_chat.query is the third variant: agent free
|
|
183
|
+
// text pasted into a user-picked chat's input box on tap — same leak class.
|
|
184
|
+
const cc = (out as { switch_inline_query_chosen_chat?: unknown })
|
|
185
|
+
.switch_inline_query_chosen_chat
|
|
186
|
+
if (cc != null && typeof cc === 'object' &&
|
|
187
|
+
typeof (cc as { query?: unknown }).query === 'string') {
|
|
188
|
+
(out as { switch_inline_query_chosen_chat?: Record<string, unknown> })
|
|
189
|
+
.switch_inline_query_chosen_chat = {
|
|
190
|
+
...(cc as Record<string, unknown>),
|
|
191
|
+
query: clamp(
|
|
192
|
+
redactFn((cc as { query: string }).query),
|
|
193
|
+
TELEGRAM_BUTTON_LIMITS.SWITCH_INLINE_QUERY_MAX),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const ct = out.copy_text
|
|
197
|
+
if (ct != null && typeof ct === 'object' &&
|
|
198
|
+
typeof (ct as { text?: unknown }).text === 'string') {
|
|
199
|
+
out.copy_text = {
|
|
200
|
+
...(ct as Record<string, unknown>),
|
|
201
|
+
text: clamp(
|
|
202
|
+
redactFn((ct as { text: string }).text),
|
|
203
|
+
TELEGRAM_BUTTON_LIMITS.COPY_TEXT_MAX),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out
|
|
207
|
+
}),
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
102
211
|
/**
|
|
103
212
|
* Extract per-button {@link AgentButtonMeta} from a raw (pre-wrap)
|
|
104
213
|
* keyboard. Returns a map keyed by the raw (unprefixed) callback_data
|
|
@@ -116,7 +225,14 @@ export function extractAgentButtonMeta(
|
|
|
116
225
|
const meta: AgentButtonMeta = {}
|
|
117
226
|
if (typeof btn.ack_text === 'string') meta.ack_text = btn.ack_text
|
|
118
227
|
if (typeof btn.single_use === 'boolean') meta.single_use = btn.single_use
|
|
119
|
-
if (
|
|
228
|
+
if (typeof btn.inline_keyboard_confirm === 'boolean') {
|
|
229
|
+
meta.inline_keyboard_confirm = btn.inline_keyboard_confirm
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
meta.ack_text != null ||
|
|
233
|
+
meta.single_use != null ||
|
|
234
|
+
meta.inline_keyboard_confirm != null
|
|
235
|
+
) {
|
|
120
236
|
out.set(btn.callback_data, meta)
|
|
121
237
|
}
|
|
122
238
|
}
|
|
@@ -149,6 +265,191 @@ export function parseAgentCallback(data: string): { raw: string } | null {
|
|
|
149
265
|
return { raw: data.slice(AGENT_CALLBACK_PREFIX.length) }
|
|
150
266
|
}
|
|
151
267
|
|
|
268
|
+
// ─── #789 button-choice-confirmation ("✅ You chose: X") ──────────────────
|
|
269
|
+
//
|
|
270
|
+
// When a user taps an agent-emitted single-use inline_keyboard button, the
|
|
271
|
+
// gateway can annotate the source message body with a
|
|
272
|
+
// "✅ You chose: <label> · HH:MM" line so the chat surface is
|
|
273
|
+
// self-documenting (mirrors the ask_user finalize UX). The DECISION and the
|
|
274
|
+
// rendered text are pure functions here so they can be unit-tested against
|
|
275
|
+
// the exact payload the gateway ships — the gateway owns only the Telegram
|
|
276
|
+
// I/O and the once-per-process warning dedupe.
|
|
277
|
+
|
|
278
|
+
/** Per-agent button-choice-confirmation config (projected into access.json). */
|
|
279
|
+
export interface ButtonChoiceConfirmationConfig {
|
|
280
|
+
enabled?: boolean
|
|
281
|
+
format?: string
|
|
282
|
+
timezone?: 'gateway' | 'utc'
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Default annotation template. `{label}` and `{time}` are substituted. */
|
|
286
|
+
export const BUTTON_CONFIRM_DEFAULT_FORMAT = '✅ You chose: {label} · {time}'
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* HTML-entity escaper for the annotation payload (shipped with
|
|
290
|
+
* parse_mode: 'HTML'). Escapes exactly the three characters Telegram's HTML
|
|
291
|
+
* parser treats specially — `&`, `<`, `>` — so arbitrary button labels and
|
|
292
|
+
* source-message text can never 400 the editMessageText call. `&` is escaped
|
|
293
|
+
* first so freshly produced entities aren't double-escaped. This is NOT the
|
|
294
|
+
* GFM-markdown escaper (#2669) — that one escapes backticks/underscores and
|
|
295
|
+
* would garble text (`Do_it` → `Do\_it`) under HTML parse mode.
|
|
296
|
+
*/
|
|
297
|
+
export function escapeHtmlEntities(s: string): string {
|
|
298
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Dedup-strip regex: matches a prior DEFAULT-shape annotation appended to the
|
|
303
|
+
* body so a retap replaces rather than duplicates it. The `<b>`/`</b>` tags
|
|
304
|
+
* are optional because Telegram returns `message.text` as PLAIN text (entity
|
|
305
|
+
* markup lives in `message.entities`, not in the text) — on a real retap the
|
|
306
|
+
* prior annotation arrives without tags. The `s` (dotAll) flag tolerates a
|
|
307
|
+
* stray newline inside an older, un-stripped label (belt-and-braces alongside
|
|
308
|
+
* the newline-stripping applied to the label on the way in). NOTE: dedup only
|
|
309
|
+
* recognizes the DEFAULT annotation shape — a custom
|
|
310
|
+
* button_choice_confirmation.format that diverges from it accumulates one
|
|
311
|
+
* line per retap instead of replacing.
|
|
312
|
+
*/
|
|
313
|
+
export const BUTTON_CONFIRM_STRIP_RE =
|
|
314
|
+
/\n\n✅ You chose: (?:<b>)?.*(?:<\/b>)? · \d{2}:\d{2}$/s
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build the confirmation line. `{label}` → the tapped button's text with
|
|
318
|
+
* newlines collapsed to spaces, trimmed to 60 chars (mirrors the toast trim),
|
|
319
|
+
* HTML-entity-escaped (`&`, `<`, `>`) and wrapped in `<b>`; `{time}` →
|
|
320
|
+
* HH:MM in the chosen timezone. `now`/`escapeLabel` are injectable for
|
|
321
|
+
* deterministic tests; `escapeLabel` defaults to the real
|
|
322
|
+
* {@link escapeHtmlEntities}. Substitutions use replacer FUNCTIONS so
|
|
323
|
+
* String.replace special patterns (`$&`, `$'`, …) in labels are inert.
|
|
324
|
+
*/
|
|
325
|
+
export function buildButtonConfirmation(args: {
|
|
326
|
+
template: string
|
|
327
|
+
label: string
|
|
328
|
+
timezone: 'gateway' | 'utc'
|
|
329
|
+
escapeLabel?: (s: string) => string
|
|
330
|
+
now?: Date
|
|
331
|
+
}): string {
|
|
332
|
+
const escape = args.escapeLabel ?? escapeHtmlEntities
|
|
333
|
+
const cleanLabel = args.label.replace(/\n/g, ' ').slice(0, 60)
|
|
334
|
+
const now = args.now ?? new Date()
|
|
335
|
+
const hh = args.timezone === 'utc'
|
|
336
|
+
? String(now.getUTCHours()).padStart(2, '0')
|
|
337
|
+
: String(now.getHours()).padStart(2, '0')
|
|
338
|
+
const mm = args.timezone === 'utc'
|
|
339
|
+
? String(now.getUTCMinutes()).padStart(2, '0')
|
|
340
|
+
: String(now.getMinutes()).padStart(2, '0')
|
|
341
|
+
return args.template
|
|
342
|
+
.replace('{label}', () => `<b>${escape(cleanLabel)}</b>`)
|
|
343
|
+
.replace('{time}', () => `${hh}:${mm}`)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Outcome of the annotate-or-strip decision for a single tap. */
|
|
347
|
+
export interface TapAnnotationResult {
|
|
348
|
+
/** True → gateway should editMessageText with `text` (and strip keyboard). */
|
|
349
|
+
annotate: boolean
|
|
350
|
+
/** Annotated body text, present iff `annotate` is true. */
|
|
351
|
+
text?: string
|
|
352
|
+
/** True → gateway should emit the once-per-process parseMode warning. */
|
|
353
|
+
warnParseMode: boolean
|
|
354
|
+
/** True → gateway should emit the once-per-process single_use-mismatch warning. */
|
|
355
|
+
warnSingleUseMismatch: boolean
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Pure decision for the #789 annotation. Resolves the per-message override
|
|
360
|
+
* (the tapped button's `inline_keyboard_confirm`) over the agent default,
|
|
361
|
+
* gates on single-use + presence of body text + a resolvable label + the
|
|
362
|
+
* `html` parse mode, and renders the annotated body (with any prior
|
|
363
|
+
* default-shape annotation stripped so a retap replaces it). Returns
|
|
364
|
+
* `annotate:false` for every skip path; the caller still performs the
|
|
365
|
+
* historical keyboard-only strip when the keyboard is single-use.
|
|
366
|
+
*
|
|
367
|
+
* The base body is HTML-entity-escaped too (Telegram hands us `message.text`
|
|
368
|
+
* as plain text, so `&`/`<`/`>` in it would otherwise 400 the HTML edit).
|
|
369
|
+
* Known limitation: because the body is rebuilt from `message.text`, any
|
|
370
|
+
* entities/formatting (bold, links, …) on the original message are lost on
|
|
371
|
+
* annotation. Documented in the config schema + CHANGELOG.
|
|
372
|
+
*/
|
|
373
|
+
export function resolveTapAnnotation(args: {
|
|
374
|
+
perMessageOverride?: boolean
|
|
375
|
+
singleUse: boolean
|
|
376
|
+
config?: ButtonChoiceConfirmationConfig
|
|
377
|
+
parseMode: 'html' | 'markdownv2' | 'text'
|
|
378
|
+
sourceText?: string
|
|
379
|
+
label?: string
|
|
380
|
+
escapeLabel?: (s: string) => string
|
|
381
|
+
now?: Date
|
|
382
|
+
}): TapAnnotationResult {
|
|
383
|
+
const shouldAnnotate = args.perMessageOverride ?? args.config?.enabled ?? false
|
|
384
|
+
const warnSingleUseMismatch = shouldAnnotate && !args.singleUse
|
|
385
|
+
|
|
386
|
+
const wantAnnotate =
|
|
387
|
+
shouldAnnotate &&
|
|
388
|
+
args.singleUse &&
|
|
389
|
+
args.sourceText != null &&
|
|
390
|
+
args.label != null
|
|
391
|
+
if (!wantAnnotate) {
|
|
392
|
+
return { annotate: false, warnParseMode: false, warnSingleUseMismatch }
|
|
393
|
+
}
|
|
394
|
+
if (args.parseMode !== 'html') {
|
|
395
|
+
return { annotate: false, warnParseMode: true, warnSingleUseMismatch }
|
|
396
|
+
}
|
|
397
|
+
const escape = args.escapeLabel ?? escapeHtmlEntities
|
|
398
|
+
// Strip a prior annotation from the RAW text first (Telegram delivers it
|
|
399
|
+
// un-tagged), then entity-escape the remainder for the HTML edit.
|
|
400
|
+
const base = escape(
|
|
401
|
+
(args.sourceText as string).replace(BUTTON_CONFIRM_STRIP_RE, ''),
|
|
402
|
+
)
|
|
403
|
+
const formatted = buildButtonConfirmation({
|
|
404
|
+
template: args.config?.format ?? BUTTON_CONFIRM_DEFAULT_FORMAT,
|
|
405
|
+
label: args.label as string,
|
|
406
|
+
timezone: args.config?.timezone ?? 'gateway',
|
|
407
|
+
escapeLabel: escape,
|
|
408
|
+
...(args.now != null ? { now: args.now } : {}),
|
|
409
|
+
})
|
|
410
|
+
return {
|
|
411
|
+
annotate: true,
|
|
412
|
+
text: `${base}\n\n${formatted}`,
|
|
413
|
+
warnParseMode: false,
|
|
414
|
+
warnSingleUseMismatch,
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Perform the annotation edit against Telegram, with a keyboard-strip
|
|
420
|
+
* fallback: if the editMessageText 400s/rejects for ANY reason (over-long
|
|
421
|
+
* body, HTML edge case, message too old, …), we still strip the inline
|
|
422
|
+
* keyboard via editMessageReplyMarkup so single-use protection holds even
|
|
423
|
+
* though the button meta has already been consumed. Extracted here (with the
|
|
424
|
+
* two Telegram calls injected) so the fallback is unit-testable.
|
|
425
|
+
*
|
|
426
|
+
* Returns 'annotated' | 'stripped-fallback' | 'failed' for observability.
|
|
427
|
+
*/
|
|
428
|
+
export async function applyTapAnnotationEdit(io: {
|
|
429
|
+
editMessageText: (text: string, other: {
|
|
430
|
+
parse_mode: 'HTML'
|
|
431
|
+
reply_markup: { inline_keyboard: never[] }
|
|
432
|
+
}) => Promise<unknown>
|
|
433
|
+
editMessageReplyMarkup: (other: {
|
|
434
|
+
reply_markup: { inline_keyboard: never[] }
|
|
435
|
+
}) => Promise<unknown>
|
|
436
|
+
}, text: string): Promise<'annotated' | 'stripped-fallback' | 'failed'> {
|
|
437
|
+
try {
|
|
438
|
+
await io.editMessageText(text, {
|
|
439
|
+
parse_mode: 'HTML',
|
|
440
|
+
reply_markup: { inline_keyboard: [] },
|
|
441
|
+
})
|
|
442
|
+
return 'annotated'
|
|
443
|
+
} catch {
|
|
444
|
+
try {
|
|
445
|
+
await io.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } })
|
|
446
|
+
return 'stripped-fallback'
|
|
447
|
+
} catch {
|
|
448
|
+
return 'failed'
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
152
453
|
/**
|
|
153
454
|
* Convenience: validate + wrap in one call. Returns either the
|
|
154
455
|
* wrapped keyboard or a structured error list — caller throws so the
|
|
@@ -41,6 +41,44 @@ export interface ModelUnavailableDetection {
|
|
|
41
41
|
raw: string
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// ─── Transient-burst signals (canonical, single source of truth) ─────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Explicit markers of a TRANSIENT per-account burst / server-side throttle —
|
|
48
|
+
* a short-term RPM/burst 429 that Claude Code retries internally with backoff,
|
|
49
|
+
* NOT the 5h/7d subscription usage-limit wall. Anthropic emits these with a
|
|
50
|
+
* `rate_limit_error` whose wording explicitly NEGATES the account-quota reading
|
|
51
|
+
* ("not your usage limit" / "would exceed your account's rate limit … try again
|
|
52
|
+
* later"). Keyed on the explicit negation so a genuine wall that merely contains
|
|
53
|
+
* the word "limit" is never down-classified.
|
|
54
|
+
*
|
|
55
|
+
* Exported so `session-tail.ts` classifies a 429 by wording against THIS list
|
|
56
|
+
* rather than hand-rolling its own copy (keeps the two in sync — issue #2922).
|
|
57
|
+
*/
|
|
58
|
+
export const transientUpstreamSignals = [
|
|
59
|
+
'not your usage limit',
|
|
60
|
+
'not your account',
|
|
61
|
+
"not your account's",
|
|
62
|
+
'temporarily limiting requests',
|
|
63
|
+
'temporarily rate',
|
|
64
|
+
'server is temporarily',
|
|
65
|
+
'would exceed your account’s rate limit',
|
|
66
|
+
"would exceed your account's rate limit",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when `text` carries an EXPLICIT transient-burst marker (see
|
|
71
|
+
* `transientUpstreamSignals`). Never throws on weird input. Used to keep the
|
|
72
|
+
* calm rate-limit path and the model-unavailable detector reading the same
|
|
73
|
+
* canonical signal list.
|
|
74
|
+
*/
|
|
75
|
+
export function isTransientUpstreamSignal(text: string): boolean {
|
|
76
|
+
if (typeof text !== 'string' || text.length === 0) return false
|
|
77
|
+
const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
|
|
78
|
+
const lower = sample.toLowerCase()
|
|
79
|
+
return transientUpstreamSignals.some(s => lower.includes(s))
|
|
80
|
+
}
|
|
81
|
+
|
|
44
82
|
// ─── Detection ───────────────────────────────────────────────────────────────
|
|
45
83
|
|
|
46
84
|
/**
|
|
@@ -77,17 +115,9 @@ export function detectModelUnavailable(
|
|
|
77
115
|
// failover that self-cancels and leaves the turn dead. These are upstream
|
|
78
116
|
// throttles Claude Code retries internally with backoff — classify them as
|
|
79
117
|
// `overload` (the calm rate-limit path) BEFORE the quota substrings run, so
|
|
80
|
-
// the negation is honoured and no failover is announced.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
'not your account',
|
|
84
|
-
"not your account's",
|
|
85
|
-
'temporarily limiting requests',
|
|
86
|
-
'temporarily rate',
|
|
87
|
-
'server is temporarily',
|
|
88
|
-
'would exceed your account’s rate limit',
|
|
89
|
-
"would exceed your account's rate limit",
|
|
90
|
-
]
|
|
118
|
+
// the negation is honoured and no failover is announced. The signal list is
|
|
119
|
+
// module-level (`transientUpstreamSignals`) so session-tail.ts classifies a
|
|
120
|
+
// 429 against the SAME canonical wording.
|
|
91
121
|
if (transientUpstreamSignals.some(s => lower.includes(s))) {
|
|
92
122
|
const resetAt = parseResetTime(sample)
|
|
93
123
|
return resetAt !== undefined
|
|
@@ -28,6 +28,7 @@ export type OperatorEventKind =
|
|
|
28
28
|
| 'unknown-5xx'
|
|
29
29
|
| 'config-warning'
|
|
30
30
|
| 'always-allow-persist-failed'
|
|
31
|
+
| 'mental-model-persist-failed'
|
|
31
32
|
|
|
32
33
|
export interface OperatorEvent {
|
|
33
34
|
kind: OperatorEventKind
|
|
@@ -420,6 +421,28 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
420
421
|
],
|
|
421
422
|
},
|
|
422
423
|
}
|
|
424
|
+
|
|
425
|
+
// #2975 Stage 1 — an operator-APPROVED mental-model persist hit the
|
|
426
|
+
// config_propose_edit rate limit, and the ONE scheduled retry at the
|
|
427
|
+
// window-open time ALSO failed. MUST be a NEW message (not a card edit):
|
|
428
|
+
// the proposal card was already edited to the "applying at HH:MM" state
|
|
429
|
+
// and card edits don't ping, so a silent edit here would leave the lost
|
|
430
|
+
// approval unnoticed. Ask the operator to re-propose so it isn't dropped.
|
|
431
|
+
case 'mental-model-persist-failed':
|
|
432
|
+
return {
|
|
433
|
+
text: [
|
|
434
|
+
`⚠️ **${agent}**'s approved mental model didn't save.`,
|
|
435
|
+
detail ? `_${detail}_` : '',
|
|
436
|
+
`The retry after the rate window also failed — ask the agent to re-propose it.`,
|
|
437
|
+
]
|
|
438
|
+
.filter(Boolean)
|
|
439
|
+
.join('\n'),
|
|
440
|
+
keyboard: {
|
|
441
|
+
inline_keyboard: [
|
|
442
|
+
[{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
|
|
443
|
+
],
|
|
444
|
+
},
|
|
445
|
+
}
|
|
423
446
|
}
|
|
424
447
|
}
|
|
425
448
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured-payload outbound redaction (#2044 coverage extension).
|
|
3
|
+
*
|
|
4
|
+
* The original #2044 outbound scrub masks agent-authored FREE TEXT on the
|
|
5
|
+
* reply / edit_message / progress_update paths (see gateway.ts
|
|
6
|
+
* `redactOutboundText`). But several other outbound surfaces carry
|
|
7
|
+
* agent-authored text to Telegram inside STRUCTURED payloads and had no
|
|
8
|
+
* redaction at all:
|
|
9
|
+
*
|
|
10
|
+
* - ask_user → the question text + every option/button label
|
|
11
|
+
* - send_checklist / update_checklist → the title + every task's text
|
|
12
|
+
*
|
|
13
|
+
* An agent that echoes a token or a DATABASE_URL it just read into an
|
|
14
|
+
* ask_user question, a button label, or a checklist task would transmit it
|
|
15
|
+
* unmasked — defeating the same scrub invariant the reply `text` path
|
|
16
|
+
* honors.
|
|
17
|
+
*
|
|
18
|
+
* These helpers apply the SAME redactor to each such field. The redactor is
|
|
19
|
+
* INJECTED (the gateway passes its `redactOutboundText`; tests pass the real
|
|
20
|
+
* `redact`) so the masking is deterministic and unit-testable at the OUTCOME
|
|
21
|
+
* level: the object returned here is exactly what the downstream send call
|
|
22
|
+
* receives, so a test can assert the secret is gone from the sent payload —
|
|
23
|
+
* not merely that a redactor was invoked.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A pure text redactor: maps input text to a masked copy. */
|
|
27
|
+
export type FieldRedactor = (text: string) => string
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Redact the two agent-authored outbound fields of an ask_user prompt: the
|
|
31
|
+
* question and each option label. Returns fresh values (the input arrays are
|
|
32
|
+
* not mutated) so the caller can assign them onto the validated args.
|
|
33
|
+
*/
|
|
34
|
+
export function redactAskUserFields(
|
|
35
|
+
question: string,
|
|
36
|
+
options: readonly string[],
|
|
37
|
+
redact: FieldRedactor,
|
|
38
|
+
): { question: string; options: string[] } {
|
|
39
|
+
return {
|
|
40
|
+
question: redact(question),
|
|
41
|
+
options: options.map((opt) => redact(opt)),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Minimal shape of a checklist task — only `text` is redacted; every other
|
|
46
|
+
* field (id, done, …) is carried through untouched. */
|
|
47
|
+
export interface ChecklistTaskLike {
|
|
48
|
+
text?: string
|
|
49
|
+
[key: string]: unknown
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Redact the agent-authored outbound fields of a checklist: the title and
|
|
54
|
+
* each task's `text`. `title`/`tasks` may be undefined (update_checklist
|
|
55
|
+
* allows partial patches); undefined passes through untouched. A task whose
|
|
56
|
+
* `text` is undefined (an id-only patch) is left as-is.
|
|
57
|
+
*/
|
|
58
|
+
export function redactChecklistFields<T extends ChecklistTaskLike>(
|
|
59
|
+
title: string | undefined,
|
|
60
|
+
tasks: readonly T[] | undefined,
|
|
61
|
+
redact: FieldRedactor,
|
|
62
|
+
): { title: string | undefined; tasks: T[] | undefined } {
|
|
63
|
+
return {
|
|
64
|
+
title: title != null ? redact(title) : title,
|
|
65
|
+
tasks: tasks?.map((task) =>
|
|
66
|
+
task.text != null ? { ...task, text: redact(task.text) } : task,
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// equivalent entities rather than accidentally re-triggering formatting or
|
|
31
31
|
// breaking out of a code span.
|
|
32
32
|
|
|
33
|
-
import { escapeMarkdown, codeSpanSafe, RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
33
|
+
import { escapeMarkdown, codeSpanSafe, escapeLinkHref, RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
34
34
|
import type {
|
|
35
35
|
Block,
|
|
36
36
|
BlockquoteNode,
|
|
@@ -47,26 +47,44 @@ import type {
|
|
|
47
47
|
// Inline rendering
|
|
48
48
|
// ---------------------------------------------------------------------------
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/** Rendering context threaded through the inline walk. `inTableCell` is set
|
|
51
|
+
* while rendering the inline content of a GFM table cell, where a literal `|`
|
|
52
|
+
* — even inside an inline-code span — would be read as a column separator and
|
|
53
|
+
* tear the row (F4). Plain-text `|` is already neutralised by `escapeMarkdown`
|
|
54
|
+
* (`|` is one of its specials); the only gap is the code span, whose content
|
|
55
|
+
* is otherwise verbatim, so we backslash-escape `|` there in the table
|
|
56
|
+
* context. GFM strips the `\` and keeps the pipe literal inside the span. */
|
|
57
|
+
interface InlineCtx {
|
|
58
|
+
inTableCell?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderInline(node: Inline, ctx: InlineCtx = {}): string {
|
|
51
62
|
switch (node.type) {
|
|
52
63
|
case "plain":
|
|
53
64
|
return escapeMarkdown(node.text);
|
|
54
65
|
case "bold":
|
|
55
|
-
return `**${renderInlineChildren(node.children)}**`;
|
|
66
|
+
return `**${renderInlineChildren(node.children, ctx)}**`;
|
|
56
67
|
case "italic":
|
|
57
|
-
return `*${renderInlineChildren(node.children)}*`;
|
|
68
|
+
return `*${renderInlineChildren(node.children, ctx)}*`;
|
|
58
69
|
case "underline":
|
|
59
|
-
return `__${renderInlineChildren(node.children)}__`;
|
|
70
|
+
return `__${renderInlineChildren(node.children, ctx)}__`;
|
|
60
71
|
case "strike":
|
|
61
|
-
return `~~${renderInlineChildren(node.children)}~~`;
|
|
72
|
+
return `~~${renderInlineChildren(node.children, ctx)}~~`;
|
|
62
73
|
case "spoiler":
|
|
63
|
-
return `||${renderInlineChildren(node.children)}||`;
|
|
74
|
+
return `||${renderInlineChildren(node.children, ctx)}||`;
|
|
64
75
|
case "highlight":
|
|
65
|
-
return `==${renderInlineChildren(node.children)}==`;
|
|
66
|
-
case "code":
|
|
67
|
-
|
|
76
|
+
return `==${renderInlineChildren(node.children, ctx)}==`;
|
|
77
|
+
case "code": {
|
|
78
|
+
const safe = codeSpanSafe(node.text);
|
|
79
|
+
// In a table cell, an unescaped `|` inside the span closes the cell early
|
|
80
|
+
// and corrupts the row; `\|` survives (GFM keeps the pipe literal in the
|
|
81
|
+
// span and drops the backslash). Elsewhere the span content is verbatim.
|
|
82
|
+
return `\`${ctx.inTableCell ? safe.replace(/\|/g, "\\|") : safe}\``;
|
|
83
|
+
}
|
|
68
84
|
case "link":
|
|
69
|
-
|
|
85
|
+
// Escape the href so a literal `)` in the URL can't terminate the
|
|
86
|
+
// destination early and break the link (F3).
|
|
87
|
+
return `[${renderInlineChildren(node.children, ctx)}](${escapeLinkHref(node.href)})`;
|
|
70
88
|
default: {
|
|
71
89
|
// Exhaustiveness guard — the IR union is closed; a new variant must be
|
|
72
90
|
// handled above rather than silently dropped.
|
|
@@ -76,8 +94,8 @@ function renderInline(node: Inline): string {
|
|
|
76
94
|
}
|
|
77
95
|
}
|
|
78
96
|
|
|
79
|
-
function renderInlineChildren(children: Inline[]): string {
|
|
80
|
-
return children.map(renderInline).join("");
|
|
97
|
+
function renderInlineChildren(children: Inline[], ctx: InlineCtx = {}): string {
|
|
98
|
+
return children.map((child) => renderInline(child, ctx)).join("");
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
// ---------------------------------------------------------------------------
|
|
@@ -154,7 +172,7 @@ function renderList(node: ListNode): string {
|
|
|
154
172
|
/** Render a single cell's inline content for a table (no line breaks — GFM
|
|
155
173
|
* table cells can't contain them; pipes are escaped defensively). */
|
|
156
174
|
function renderTableCell(cells: TableRow["cells"][number]): string {
|
|
157
|
-
return renderInlineChildren(cells.children).replace(/\n+/g, " ");
|
|
175
|
+
return renderInlineChildren(cells.children, { inTableCell: true }).replace(/\n+/g, " ");
|
|
158
176
|
}
|
|
159
177
|
|
|
160
178
|
function alignSeparator(align: TableNode["align"][number]): string {
|