switchroom 0.18.15 → 0.18.18
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 +16 -0
- package/dist/auth-broker/index.js +445 -10
- package/dist/cli/notion-write-pretool.mjs +16 -0
- package/dist/cli/switchroom.js +654 -479
- package/dist/host-control/main.js +20 -1
- package/dist/vault/approvals/kernel-server.js +16 -0
- package/dist/vault/broker/server.js +16 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +81 -139
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1758 -661
- package/telegram-plugin/dist/server.js +26 -1
- package/telegram-plugin/draft-stream.ts +78 -3
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +64 -22
- package/telegram-plugin/gateway/effort-command.ts +9 -7
- package/telegram-plugin/gateway/gateway.ts +627 -291
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
- package/telegram-plugin/gateway/model-command.ts +96 -18
- package/telegram-plugin/gateway/pending-session-command.ts +10 -8
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +141 -172
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/litellm-local-notice.ts +189 -0
- package/telegram-plugin/llm-error-present.ts +436 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/quota-watch.ts +16 -4
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/runtime-metrics.ts +16 -0
- package/telegram-plugin/send-gate-degraded.test.ts +161 -8
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +246 -23
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/stream-controller.ts +143 -20
- package/telegram-plugin/stream-reply-handler.ts +12 -2
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bot-api.harness.ts +7 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/draft-stream.test.ts +110 -1
- package/telegram-plugin/tests/effort-command.test.ts +4 -4
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +5 -4
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
- package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
- package/telegram-plugin/tests/model-command.test.ts +84 -1
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/quota-watch.test.ts +21 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +8 -3
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/session-model-file.test.ts +7 -155
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +212 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/worker-activity-feed.ts +543 -368
|
@@ -118,6 +118,32 @@ export type PriorityClass = 'critical' | 'useful' | 'cosmetic'
|
|
|
118
118
|
*/
|
|
119
119
|
export const UNTAGGED_SEND_CLASS: PriorityClass = 'critical'
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Distinguishable resolution value for a SHED call (#3110 review F1).
|
|
123
|
+
*
|
|
124
|
+
* `undefined` was overloaded three ways at the robustApiCall seam: a gate
|
|
125
|
+
* SHED (cosmetic call dropped under pressure — did NOT land), a gate no-op
|
|
126
|
+
* drop (identical payload already on screen — benign), and the retry
|
|
127
|
+
* policy's swallowed benign 400s ("message is not modified" — also benign).
|
|
128
|
+
* A caller that needs to know "did my edit land?" (the draft stream's
|
|
129
|
+
* shed-honesty handling in stream-controller.ts) could not tell these
|
|
130
|
+
* apart, and treating every `undefined` as a shed froze multi-piece
|
|
131
|
+
* streams on perfectly healthy chats.
|
|
132
|
+
*
|
|
133
|
+
* A shed now resolves THIS sentinel instead. `Symbol.for` keys it in the
|
|
134
|
+
* global symbol registry so duplicated module instances (server + gateway
|
|
135
|
+
* builds) agree on identity. The no-op drop and coalesced-revert drop keep
|
|
136
|
+
* resolving `undefined` — for those the payload IS on screen. Callers that
|
|
137
|
+
* ignore the result (reactions, typing, fire-and-forget card edits) are
|
|
138
|
+
* unaffected either way.
|
|
139
|
+
*/
|
|
140
|
+
export const SEND_GATE_SHED: unique symbol = Symbol.for('switchroom.send-gate.shed')
|
|
141
|
+
|
|
142
|
+
/** True when a gate result is the {@link SEND_GATE_SHED} sentinel. */
|
|
143
|
+
export function isSendGateShed(value: unknown): value is typeof SEND_GATE_SHED {
|
|
144
|
+
return value === SEND_GATE_SHED
|
|
145
|
+
}
|
|
146
|
+
|
|
121
147
|
/**
|
|
122
148
|
* Extra metadata a call site can attach so the gate can key the right buckets.
|
|
123
149
|
* All fields optional — a call with none still passes the global bucket. These
|
|
@@ -159,8 +185,9 @@ export interface BucketCounters {
|
|
|
159
185
|
dropped: number
|
|
160
186
|
/**
|
|
161
187
|
* Cosmetic calls shed under pressure — no token free OR a flood window open
|
|
162
|
-
* (part3-design §2). A shed resolves
|
|
163
|
-
*
|
|
188
|
+
* (part3-design §2). A shed resolves the SEND_GATE_SHED sentinel (F1 —
|
|
189
|
+
* distinguishable from a benign no-op drop's `undefined`); the next send
|
|
190
|
+
* carries full state.
|
|
164
191
|
*/
|
|
165
192
|
shed: number
|
|
166
193
|
/** Useful calls dropped because they exceeded their queue TTL (part3-design §2). */
|
|
@@ -277,6 +304,21 @@ export interface SendGateConfig {
|
|
|
277
304
|
* `initialWindows` applied at construction (those already came from disk).
|
|
278
305
|
*/
|
|
279
306
|
onWindowOpen?: (scopeKey: string, untilTs: number) => void
|
|
307
|
+
/**
|
|
308
|
+
* Conservative flood-scope posture (#3111). When `false` (the DEFAULT), a 429
|
|
309
|
+
* on a call bound to a specific chat opens ONLY that chat's (and, for groups,
|
|
310
|
+
* that group's) flood window — NOT a blanket `global` window — so a genuinely
|
|
311
|
+
* chat-scoped 429 (e.g. a per-group 20/min limit) cannot fail-fast CRITICAL
|
|
312
|
+
* sends to every other chat. `global` is opened only when the 429 carries no
|
|
313
|
+
* finer scope (no `chat_id`), i.e. the evidence is genuinely global.
|
|
314
|
+
*
|
|
315
|
+
* When `true`, the pre-#3111 posture is restored: EVERY 429 additionally opens
|
|
316
|
+
* the `global` window. Telegram 429s are per-bot-token, so a flood often IS
|
|
317
|
+
* global; an operator who prefers to over-suppress rather than risk a missed
|
|
318
|
+
* global ban can opt back in via
|
|
319
|
+
* `SWITCHROOM_TG_SEND_GATE_CONSERVATIVE_GLOBAL=1`.
|
|
320
|
+
*/
|
|
321
|
+
conservativeGlobalFloodScope?: boolean
|
|
280
322
|
}
|
|
281
323
|
|
|
282
324
|
/**
|
|
@@ -435,22 +477,58 @@ export interface SendGate {
|
|
|
435
477
|
* by PR 2 on a 429 and at boot from the persisted `flood-wait.json`.
|
|
436
478
|
*/
|
|
437
479
|
openFloodWindow(scopeKey: string, untilTs: number): void
|
|
480
|
+
/**
|
|
481
|
+
* Open the SCOPE-PRECISE flood windows a 429 on a call with these `opts`
|
|
482
|
+
* implies (#3111). Used by the gateway's `onFloodWait` hook, which sees a
|
|
483
|
+
* SHORT slept-and-retried 429 the gate's own `FLOOD_WAIT_ACTIVE` catch never
|
|
484
|
+
* observes. Opens `global` only when `opts` carries no `chat_id` (genuinely
|
|
485
|
+
* global evidence) or the conservative posture is on — so a chat-scoped 429
|
|
486
|
+
* does not suppress unrelated chats. A pure no-op when the gate is disabled.
|
|
487
|
+
*/
|
|
488
|
+
openScopedFloodWindows(opts: SendGateOpts | undefined, untilTs: number): void
|
|
438
489
|
/** Snapshot of counters + current bucket fill. */
|
|
439
490
|
stats(): SendGateStats
|
|
440
491
|
}
|
|
441
492
|
|
|
442
493
|
const GROUP_TYPES = new Set<ChatType>(['group', 'supergroup'])
|
|
443
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Compile-time default rate limits for the send gate's token buckets and edit
|
|
497
|
+
* floor — the values `createSendGate` falls back to when a field is omitted.
|
|
498
|
+
*
|
|
499
|
+
* Exposed so the config plumbing (`channels.telegram.send_gate.*` →
|
|
500
|
+
* `SWITCHROOM_TG_SEND_GATE_*` env → `sendGateConfigFromEnv`) defaults to the
|
|
501
|
+
* SAME numbers, and so a test can PIN them: a future accidental change to any
|
|
502
|
+
* default fails that pin. Changing a value here is a real behaviour change for
|
|
503
|
+
* every install — do it deliberately, never as a drive-by edit.
|
|
504
|
+
*/
|
|
505
|
+
export const SEND_GATE_DEFAULTS = {
|
|
506
|
+
/** Global bucket sustained rate (tokens/sec). */
|
|
507
|
+
globalPerSec: 25,
|
|
508
|
+
/** Global burst capacity (headroom under Telegram's ~30/s). */
|
|
509
|
+
globalBurst: 4,
|
|
510
|
+
/** Per-chat sustained rate (tokens/sec). */
|
|
511
|
+
perChatPerSec: 1,
|
|
512
|
+
/** Per-chat burst capacity. */
|
|
513
|
+
perChatBurst: 3,
|
|
514
|
+
/** Per-group sustained rate (tokens/min). */
|
|
515
|
+
perGroupPerMin: 18,
|
|
516
|
+
/** Per-group burst capacity (headroom under 20/min). */
|
|
517
|
+
perGroupBurst: 2,
|
|
518
|
+
/** Minimum ms between edits of the same message_id. */
|
|
519
|
+
editFloorMs: 1500,
|
|
520
|
+
} as const
|
|
521
|
+
|
|
444
522
|
export function createSendGate(config: SendGateConfig): SendGate {
|
|
445
523
|
const enabled = config.enabled
|
|
446
524
|
const clock = config.clock ?? systemClock
|
|
447
|
-
const globalPerSec = config.globalPerSec ??
|
|
448
|
-
const globalBurst = config.globalBurst ??
|
|
449
|
-
const perChatPerSec = config.perChatPerSec ??
|
|
450
|
-
const perChatBurst = config.perChatBurst ??
|
|
451
|
-
const perGroupPerMin = config.perGroupPerMin ??
|
|
452
|
-
const perGroupBurst = config.perGroupBurst ??
|
|
453
|
-
const editFloorMs = config.editFloorMs ??
|
|
525
|
+
const globalPerSec = config.globalPerSec ?? SEND_GATE_DEFAULTS.globalPerSec
|
|
526
|
+
const globalBurst = config.globalBurst ?? SEND_GATE_DEFAULTS.globalBurst
|
|
527
|
+
const perChatPerSec = config.perChatPerSec ?? SEND_GATE_DEFAULTS.perChatPerSec
|
|
528
|
+
const perChatBurst = config.perChatBurst ?? SEND_GATE_DEFAULTS.perChatBurst
|
|
529
|
+
const perGroupPerMin = config.perGroupPerMin ?? SEND_GATE_DEFAULTS.perGroupPerMin
|
|
530
|
+
const perGroupBurst = config.perGroupBurst ?? SEND_GATE_DEFAULTS.perGroupBurst
|
|
531
|
+
const editFloorMs = config.editFloorMs ?? SEND_GATE_DEFAULTS.editFloorMs
|
|
454
532
|
const messageStateTtlMs = config.messageStateTtlMs ?? 60_000
|
|
455
533
|
const maxMessageStates = config.maxMessageStates ?? 5_000
|
|
456
534
|
const usefulTtlMs = config.usefulTtlMs ?? 120_000
|
|
@@ -458,6 +536,7 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
458
536
|
const criticalJitterMaxMs = config.criticalJitterMaxMs ?? 250
|
|
459
537
|
const jitter = config.jitter ?? Math.random
|
|
460
538
|
const onWindowOpen = config.onWindowOpen
|
|
539
|
+
const conservativeGlobalFloodScope = config.conservativeGlobalFloodScope ?? false
|
|
461
540
|
|
|
462
541
|
const counters: BucketCounters = {
|
|
463
542
|
sent: 0,
|
|
@@ -570,18 +649,35 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
570
649
|
}
|
|
571
650
|
|
|
572
651
|
/**
|
|
573
|
-
* Open
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
652
|
+
* Open the flood windows a 429 on this call implies at `untilTs`, at the
|
|
653
|
+
* FINEST scope the evidence supports (#3111). Called when a wrapped send
|
|
654
|
+
* surfaces a `FLOOD_WAIT_ACTIVE` (a real 429 or a pre-call short-circuit), and
|
|
655
|
+
* by the gateway's `onFloodWait` hook for a SHORT slept-and-retried 429 (via
|
|
656
|
+
* the public `openScopedFloodWindows`), so the ban is remembered at the finest
|
|
657
|
+
* scope we know (part3-design §7).
|
|
658
|
+
*
|
|
659
|
+
* SCOPE PRECISION (#3111): a call bound to a specific `chat_id` opens ONLY that
|
|
660
|
+
* chat's (and, for groups, that group's) window — NOT a blanket `global`
|
|
661
|
+
* window that would suppress unrelated chats. `global` is opened only when the
|
|
662
|
+
* call carries NO finer scope (no `chat_id`) — i.e. the evidence is genuinely
|
|
663
|
+
* global — OR when `conservativeGlobalFloodScope` restores the pre-#3111
|
|
664
|
+
* always-open-global posture. Telegram 429s are per-bot-token so a flood is
|
|
665
|
+
* OFTEN global, but a genuinely chat-scoped 429 (e.g. a per-group 20/min
|
|
666
|
+
* limit) must not fail-fast CRITICAL sends to every other chat. The
|
|
667
|
+
* per-message (`msg-edit:`) window is additive whenever a `messageId` is
|
|
668
|
+
* present. Monotonic/idempotent: `applyWindow` only ever extends.
|
|
578
669
|
*/
|
|
579
670
|
function openScopedWindowsForOpts(opts: SendGateOpts | undefined, untilTs: number): void {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
671
|
+
const hasChatScope = opts?.chat_id != null && opts.chat_id !== ''
|
|
672
|
+
// Open `global` only when the 429 has no finer scope (genuinely global), or
|
|
673
|
+
// when the operator opted into the conservative always-global posture.
|
|
674
|
+
if (!hasChatScope || conservativeGlobalFloodScope) {
|
|
675
|
+
applyWindow('global', untilTs, true)
|
|
676
|
+
}
|
|
677
|
+
if (hasChatScope) {
|
|
678
|
+
applyWindow(`chat:${opts!.chat_id}`, untilTs, true)
|
|
679
|
+
if (opts!.chatType && GROUP_TYPES.has(opts!.chatType)) {
|
|
680
|
+
applyWindow(`group:${opts!.chat_id}`, untilTs, true)
|
|
585
681
|
}
|
|
586
682
|
}
|
|
587
683
|
if (opts?.messageId != null) {
|
|
@@ -913,7 +1009,9 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
913
1009
|
const msgWait = state.suppressedUntilMs > now ? state.suppressedUntilMs - now : 0
|
|
914
1010
|
if (wait > 0 || msgWait > 0) {
|
|
915
1011
|
counters.shed++
|
|
916
|
-
|
|
1012
|
+
// Distinguishable from the no-op drop below (undefined): a shed did
|
|
1013
|
+
// NOT land, and edit-driving callers must be able to tell (F1).
|
|
1014
|
+
return Promise.resolve(SEND_GATE_SHED as unknown as T)
|
|
917
1015
|
}
|
|
918
1016
|
}
|
|
919
1017
|
|
|
@@ -991,7 +1089,8 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
991
1089
|
const outcome = await admitPriority(bucketsFor(opts), priority)
|
|
992
1090
|
if (outcome.result === 'shed') {
|
|
993
1091
|
counters.shed++
|
|
994
|
-
|
|
1092
|
+
// See SEND_GATE_SHED: distinguishable "did not land" resolution (F1).
|
|
1093
|
+
return SEND_GATE_SHED as unknown as T
|
|
995
1094
|
}
|
|
996
1095
|
if (outcome.result === 'expired') {
|
|
997
1096
|
counters.expired++
|
|
@@ -1037,7 +1136,18 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
1037
1136
|
}
|
|
1038
1137
|
}
|
|
1039
1138
|
|
|
1040
|
-
|
|
1139
|
+
/**
|
|
1140
|
+
* Public, scope-precise window opener for the gateway's `onFloodWait` hook
|
|
1141
|
+
* (#3111). Flag-OFF is a pure no-op (mirrors `openFloodWindow`'s M3 guard) so
|
|
1142
|
+
* a 429 never mutates suppression or writes `flood-windows.json` while the gate
|
|
1143
|
+
* is disabled.
|
|
1144
|
+
*/
|
|
1145
|
+
function openScopedFloodWindows(opts: SendGateOpts | undefined, untilTs: number): void {
|
|
1146
|
+
if (!enabled) return
|
|
1147
|
+
openScopedWindowsForOpts(opts, untilTs)
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
return { gate, openFloodWindow, openScopedFloodWindows, stats }
|
|
1041
1151
|
}
|
|
1042
1152
|
|
|
1043
1153
|
/**
|
|
@@ -1051,6 +1161,119 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
1051
1161
|
export function sendGateEnabledFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
1052
1162
|
const v = env.SWITCHROOM_TELEGRAM_SEND_GATE
|
|
1053
1163
|
if (v == null) return true
|
|
1164
|
+
return !isOffValue(v)
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** Shared parse for the default-on kill-switch grammar: `0/false/off/no`. */
|
|
1168
|
+
function isOffValue(v: string): boolean {
|
|
1054
1169
|
const t = v.trim().toLowerCase()
|
|
1055
|
-
return
|
|
1170
|
+
return t === '0' || t === 'false' || t === 'off' || t === 'no'
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Parse a strictly-positive finite number env var; blank/NaN/≤0 → undefined. */
|
|
1174
|
+
function parsePositiveNumber(raw: string | undefined): number | undefined {
|
|
1175
|
+
if (raw == null || raw.trim() === '') return undefined
|
|
1176
|
+
const n = Number(raw)
|
|
1177
|
+
return Number.isFinite(n) && n > 0 ? n : undefined
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** Parse a positive integer env var; blank/NaN/non-int/≤0 → undefined. */
|
|
1181
|
+
function parsePositiveInt(raw: string | undefined): number | undefined {
|
|
1182
|
+
if (raw == null || raw.trim() === '') return undefined
|
|
1183
|
+
const n = Number(raw)
|
|
1184
|
+
return Number.isInteger(n) && n > 0 ? n : undefined
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** Parse a non-negative integer env var; blank/NaN/non-int/<0 → undefined. */
|
|
1188
|
+
function parseNonNegativeInt(raw: string | undefined): number | undefined {
|
|
1189
|
+
if (raw == null || raw.trim() === '') return undefined
|
|
1190
|
+
const n = Number(raw)
|
|
1191
|
+
return Number.isInteger(n) && n >= 0 ? n : undefined
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Parse a boolean env flag using the repo's on/off grammar
|
|
1196
|
+
* (`1/true/on/yes` → true, `0/false/off/no` → false). Blank or unrecognised →
|
|
1197
|
+
* undefined so the caller keeps its built-in default (#3111).
|
|
1198
|
+
*/
|
|
1199
|
+
function parseBoolFlag(raw: string | undefined): boolean | undefined {
|
|
1200
|
+
if (raw == null || raw.trim() === '') return undefined
|
|
1201
|
+
const t = raw.trim().toLowerCase()
|
|
1202
|
+
if (t === '1' || t === 'true' || t === 'on' || t === 'yes') return true
|
|
1203
|
+
if (isOffValue(t)) return false
|
|
1204
|
+
return undefined
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* The resolved rate-limit + enable slice `createSendGate` consumes, sourced
|
|
1209
|
+
* from env (which the scaffold populates from `channels.telegram.send_gate.*`).
|
|
1210
|
+
* Only fields the operator SET are present; an unset rate field is absent so
|
|
1211
|
+
* `createSendGate` applies its `SEND_GATE_DEFAULTS` fallback (unset ⇒ today's
|
|
1212
|
+
* exact behaviour).
|
|
1213
|
+
*/
|
|
1214
|
+
export type SendGateEnvConfig = Pick<SendGateConfig, 'enabled'> &
|
|
1215
|
+
Partial<
|
|
1216
|
+
Pick<
|
|
1217
|
+
SendGateConfig,
|
|
1218
|
+
| 'globalPerSec'
|
|
1219
|
+
| 'globalBurst'
|
|
1220
|
+
| 'perChatPerSec'
|
|
1221
|
+
| 'perChatBurst'
|
|
1222
|
+
| 'perGroupPerMin'
|
|
1223
|
+
| 'perGroupBurst'
|
|
1224
|
+
| 'editFloorMs'
|
|
1225
|
+
| 'conservativeGlobalFloodScope'
|
|
1226
|
+
>
|
|
1227
|
+
>
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Resolve the send gate's `enabled` flag + tunable rate limits from env.
|
|
1231
|
+
*
|
|
1232
|
+
* ENABLED PRECEDENCE (least-surprising, documented):
|
|
1233
|
+
* 1. `SWITCHROOM_TELEGRAM_SEND_GATE` — the operator break-glass valve. When
|
|
1234
|
+
* EXPLICITLY set it ALWAYS wins (unchanged kill-switch semantics), on or
|
|
1235
|
+
* off, regardless of config. An operator disabling the gate in an incident
|
|
1236
|
+
* must not be overridden by YAML.
|
|
1237
|
+
* 2. `SWITCHROOM_TG_SEND_GATE_ENABLED` — from `channels.telegram.send_gate.
|
|
1238
|
+
* enabled`. Decides only when the valve above is unset.
|
|
1239
|
+
* 3. Both unset ⇒ `true` (the gate is ON by default, unchanged).
|
|
1240
|
+
*
|
|
1241
|
+
* The rate knobs come solely from config (there was never an env override for
|
|
1242
|
+
* them). Config values are validated LOUDLY at load by the Zod schema; this
|
|
1243
|
+
* parse is a defensive net — a hand-set malformed env falls back to undefined
|
|
1244
|
+
* so `createSendGate` uses its built-in default rather than wedging sends.
|
|
1245
|
+
*/
|
|
1246
|
+
export function sendGateConfigFromEnv(
|
|
1247
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
1248
|
+
): SendGateEnvConfig {
|
|
1249
|
+
const killSwitch = env.SWITCHROOM_TELEGRAM_SEND_GATE
|
|
1250
|
+
let enabled: boolean
|
|
1251
|
+
if (killSwitch != null && killSwitch.trim() !== '') {
|
|
1252
|
+
enabled = !isOffValue(killSwitch)
|
|
1253
|
+
} else {
|
|
1254
|
+
const cfg = env.SWITCHROOM_TG_SEND_GATE_ENABLED
|
|
1255
|
+
enabled = cfg != null && cfg.trim() !== '' ? !isOffValue(cfg) : true
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const out: SendGateEnvConfig = { enabled }
|
|
1259
|
+
const globalPerSec = parsePositiveNumber(env.SWITCHROOM_TG_SEND_GATE_GLOBAL_PER_SEC)
|
|
1260
|
+
if (globalPerSec !== undefined) out.globalPerSec = globalPerSec
|
|
1261
|
+
const globalBurst = parsePositiveInt(env.SWITCHROOM_TG_SEND_GATE_GLOBAL_BURST)
|
|
1262
|
+
if (globalBurst !== undefined) out.globalBurst = globalBurst
|
|
1263
|
+
const perChatPerSec = parsePositiveNumber(env.SWITCHROOM_TG_SEND_GATE_PER_CHAT_PER_SEC)
|
|
1264
|
+
if (perChatPerSec !== undefined) out.perChatPerSec = perChatPerSec
|
|
1265
|
+
const perChatBurst = parsePositiveInt(env.SWITCHROOM_TG_SEND_GATE_PER_CHAT_BURST)
|
|
1266
|
+
if (perChatBurst !== undefined) out.perChatBurst = perChatBurst
|
|
1267
|
+
const perGroupPerMin = parsePositiveNumber(env.SWITCHROOM_TG_SEND_GATE_PER_GROUP_PER_MIN)
|
|
1268
|
+
if (perGroupPerMin !== undefined) out.perGroupPerMin = perGroupPerMin
|
|
1269
|
+
const perGroupBurst = parsePositiveInt(env.SWITCHROOM_TG_SEND_GATE_PER_GROUP_BURST)
|
|
1270
|
+
if (perGroupBurst !== undefined) out.perGroupBurst = perGroupBurst
|
|
1271
|
+
const editFloorMs = parseNonNegativeInt(env.SWITCHROOM_TG_SEND_GATE_EDIT_FLOOR_MS)
|
|
1272
|
+
if (editFloorMs !== undefined) out.editFloorMs = editFloorMs
|
|
1273
|
+
// #3111 break-glass: restore the pre-#3111 always-open-global flood posture.
|
|
1274
|
+
// Unset ⇒ scope-precise default (global opened only on genuinely global 429s).
|
|
1275
|
+
const conservativeGlobal = parseBoolFlag(env.SWITCHROOM_TG_SEND_GATE_CONSERVATIVE_GLOBAL)
|
|
1276
|
+
if (conservativeGlobal !== undefined) out.conservativeGlobalFloodScope = conservativeGlobal
|
|
1277
|
+
|
|
1278
|
+
return out
|
|
1056
1279
|
}
|
|
@@ -333,6 +333,22 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
|
|
|
333
333
|
const message = obj.message as Record<string, unknown> | undefined
|
|
334
334
|
const content = message?.content as Array<Record<string, unknown>> | undefined
|
|
335
335
|
if (!Array.isArray(content)) return []
|
|
336
|
+
// #llm-error-surfacing — DETERMINISTIC TERMINAL SOURCE. Claude Code writes a
|
|
337
|
+
// usage-limit / API error as a SYNTHETIC ASSISTANT MESSAGE
|
|
338
|
+
// (`isApiErrorMessage: true`) whose `content[].text` is the raw error string
|
|
339
|
+
// ("You've hit your limit · resets … b'{\"type\":\"error\"…}'"). That text
|
|
340
|
+
// used to fan out to the reply/answer passthrough AND the turn-end "done"
|
|
341
|
+
// card as if it were the model's answer. It is NOT an answer — it is an
|
|
342
|
+
// error the operator-event pipeline (detectErrorInTranscriptLine →
|
|
343
|
+
// humanized card) owns. Suppress the text/thinking events here at the ONE
|
|
344
|
+
// authoritative source so neither downstream surface can relay the raw
|
|
345
|
+
// bytes; the humanized card is rendered independently from the same line.
|
|
346
|
+
if (obj.isApiErrorMessage === true) {
|
|
347
|
+
const mainModel = message?.model
|
|
348
|
+
return typeof mainModel === 'string' && !isModelSentinel(mainModel)
|
|
349
|
+
? [{ kind: 'model', model: mainModel }]
|
|
350
|
+
: []
|
|
351
|
+
}
|
|
336
352
|
const events: SessionEvent[] = []
|
|
337
353
|
// Live model capture: `message.model` is the exact resolved model that
|
|
338
354
|
// served THIS assistant API call. Emit it FIRST (before the content events)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/local-time.ts — operator-facing local wall-clock formatting.
|
|
3
|
+
*
|
|
4
|
+
* These four primitives render an epoch-ms instant in an operator's CONFIGURED
|
|
5
|
+
* IANA timezone (e.g. `Australia/Melbourne`) instead of UTC, so a card reading
|
|
6
|
+
* "clears ~4:52pm AEST" is legible at a glance where a raw `…T04:52:00Z UTC`
|
|
7
|
+
* is not. They were originally file-private in `send-gate-observability.ts`
|
|
8
|
+
* (#3084 / PR 3205); extracted here so BOTH the flood-window observer and the
|
|
9
|
+
* humanized LLM-error card (`llm-error-present.ts`) render timestamps through
|
|
10
|
+
* ONE source of truth rather than two drifting copies.
|
|
11
|
+
*
|
|
12
|
+
* Pure — no I/O, no clock, no env. Every function is total (never throws) and
|
|
13
|
+
* degrades gracefully: an unknown tz falls back to the offset form or the tz
|
|
14
|
+
* string itself. `tz='UTC'` reproduces the pre-extraction behaviour exactly, so
|
|
15
|
+
* `send-gate-observability`'s snapshot-locked output is byte-identical.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Lowercased wall-clock time in `tz`, e.g. `9:39am`. */
|
|
19
|
+
export function fmtLocalClock(ms: number, tz: string): string {
|
|
20
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
21
|
+
timeZone: tz,
|
|
22
|
+
hour: 'numeric',
|
|
23
|
+
minute: '2-digit',
|
|
24
|
+
hour12: true,
|
|
25
|
+
})
|
|
26
|
+
.format(new Date(ms))
|
|
27
|
+
.replace(/\s([AP])M$/, (_m, p: string) => `${p.toLowerCase()}m`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Compact local date, e.g. `12 Jul` — used only to disambiguate day-spanning windows. */
|
|
31
|
+
export function fmtLocalDate(ms: number, tz: string): string {
|
|
32
|
+
return new Intl.DateTimeFormat('en-GB', {
|
|
33
|
+
timeZone: tz,
|
|
34
|
+
day: 'numeric',
|
|
35
|
+
month: 'short',
|
|
36
|
+
}).format(new Date(ms))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Calendar day (`YYYY-MM-DD`) in `tz`, for a timezone-correct "same day?" test. */
|
|
40
|
+
export function localDay(ms: number, tz: string): string {
|
|
41
|
+
return new Intl.DateTimeFormat('en-CA', {
|
|
42
|
+
timeZone: tz,
|
|
43
|
+
year: 'numeric',
|
|
44
|
+
month: '2-digit',
|
|
45
|
+
day: '2-digit',
|
|
46
|
+
}).format(new Date(ms))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Short tz abbreviation for `tz` at `ms`, e.g. `AEST`, `EDT`, `BST`. ICU only
|
|
51
|
+
* surfaces the common alpha abbreviation for a locale whose region matches the
|
|
52
|
+
* zone, so we try a small locale list and take the first genuine abbreviation;
|
|
53
|
+
* zones with no common abbreviation (e.g. Asia/Kolkata) fall back to the offset
|
|
54
|
+
* form (`GMT+5:30`), and `UTC` stays `UTC`.
|
|
55
|
+
*/
|
|
56
|
+
export function tzAbbrev(ms: number, tz: string): string {
|
|
57
|
+
const at = new Date(ms)
|
|
58
|
+
for (const loc of ['en-US', 'en-AU', 'en-GB']) {
|
|
59
|
+
const v = new Intl.DateTimeFormat(loc, { timeZone: tz, timeZoneName: 'short' })
|
|
60
|
+
.formatToParts(at)
|
|
61
|
+
.find((p) => p.type === 'timeZoneName')?.value
|
|
62
|
+
if (v && !/^(?:GMT|UTC)/i.test(v)) return v
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'short' })
|
|
66
|
+
.formatToParts(at)
|
|
67
|
+
.find((p) => p.type === 'timeZoneName')?.value ?? tz
|
|
68
|
+
)
|
|
69
|
+
}
|