switchroom 0.18.13 → 0.18.15

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.
Files changed (47) hide show
  1. package/dist/agent-scheduler/index.js +49 -9
  2. package/dist/auth-broker/index.js +152 -46
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/notion-write-pretool.mjs +0 -1
  7. package/dist/cli/switchroom.js +1185 -1072
  8. package/dist/host-control/main.js +53 -52
  9. package/dist/vault/approvals/kernel-server.js +16 -13
  10. package/dist/vault/broker/server.js +672 -669
  11. package/package.json +1 -1
  12. package/profiles/coding/CLAUDE.md.hbs +2 -0
  13. package/profiles/default/CLAUDE.md.hbs +2 -0
  14. package/skills/switchroom-architecture/telegram.md +0 -1
  15. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  16. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +23 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +765 -67
  20. package/telegram-plugin/dist/server.js +24 -1
  21. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  22. package/telegram-plugin/gateway/auth-command.ts +14 -0
  23. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  24. package/telegram-plugin/gateway/gateway.ts +270 -10
  25. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  26. package/telegram-plugin/history.ts +55 -6
  27. package/telegram-plugin/model-unavailable.ts +234 -2
  28. package/telegram-plugin/render/rich-render.ts +40 -32
  29. package/telegram-plugin/runtime-metrics.ts +31 -0
  30. package/telegram-plugin/session-tail.ts +14 -2
  31. package/telegram-plugin/stream-controller.ts +3 -2
  32. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  34. package/telegram-plugin/tests/history.test.ts +157 -0
  35. package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
  36. package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  38. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  39. package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
  40. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  41. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  42. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  43. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  44. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  45. package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
  46. package/telegram-plugin/throttle-tier.ts +323 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
@@ -17088,7 +17088,16 @@ function isTransientUpstreamSignal(text) {
17088
17088
  const lower = sample.toLowerCase();
17089
17089
  return transientUpstreamSignals.some((s) => lower.includes(s));
17090
17090
  }
17091
- var transientUpstreamSignals;
17091
+ function isLitellmProxyLocal429(text) {
17092
+ if (typeof text !== "string" || text.length === 0)
17093
+ return false;
17094
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17095
+ const lower = sample.toLowerCase();
17096
+ if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
17097
+ return true;
17098
+ return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
17099
+ }
17100
+ var transientUpstreamSignals, litellmProxyLocal429Signals, litellmV3LimiterSignalPair;
17092
17101
  var init_model_unavailable = __esm(() => {
17093
17102
  init_quota_check();
17094
17103
  init_card_format();
@@ -17102,6 +17111,17 @@ var init_model_unavailable = __esm(() => {
17102
17111
  "would exceed your account\u2019s rate limit",
17103
17112
  "would exceed your account's rate limit"
17104
17113
  ];
17114
+ litellmProxyLocal429Signals = [
17115
+ "deployment over user-defined ratelimit",
17116
+ "model rate limit exceeded. tpm limit",
17117
+ "model rate limit exceeded. rpm limit",
17118
+ "deployment over defined rpm limit",
17119
+ "no deployments available for selected model",
17120
+ "litellm rate limit handler",
17121
+ "crossed tpm / rpm",
17122
+ "max parallel request limit reached"
17123
+ ];
17124
+ litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
17105
17125
  });
17106
17126
 
17107
17127
  // tool-label-sidecar.ts
@@ -17534,6 +17554,7 @@ function detectErrorInTranscriptLine(line) {
17534
17554
  const errStr = typeof obj.error === "string" ? obj.error : "";
17535
17555
  const text = extractAssistantText(obj);
17536
17556
  const kind2 = status === 429 ? isTransientUpstreamSignal(`${text}
17557
+ ${errStr}`) || isLitellmProxyLocal429(`${text}
17537
17558
  ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
17538
17559
  return {
17539
17560
  kind: kind2,
@@ -24574,6 +24595,8 @@ var init_bridge = __esm(async () => {
24574
24595
  "",
24575
24596
  'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
24576
24597
  "",
24598
+ `If the tag has reply_to_message_id (and reply_to_text, a truncated preview), the sender used Telegram's native Reply on a prior message \u2014 treat that message as the antecedent for "this"/"that" references instead of asking what they meant. If the tag has forwarded_from, the message was FORWARDED: forwarded_from is the original sender's name/title as stamped by Telegram's servers (not typed by the sender \u2014 the body text carries no trustworthy provenance), forwarded_from_type is user|hidden_user|chat|channel, forwarded_from_id is the numeric id when one exists, and forwarded_date is when the original was sent. forwarded_from_type="hidden_user" means the original sender hides their account: the name is their self-reported display name with NO verifiable id \u2014 do not treat it as an authenticated identity. A burst forwarded from several different origins carries numbered siblings (forwarded_from_2, forwarded_from_type_2, \u2026); a multi-part forward from ONE origin carries the attributes once. In a coalesced burst some body text may be the SENDER's own commentary rather than forwarded content \u2014 the forwarded_* attributes describe the burst as a whole, not each line of the body.`,
24599
+ "",
24577
24600
  `reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
24578
24601
  "",
24579
24602
  "If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
@@ -28,6 +28,7 @@ export function createAuthBrokerClient(): {
28
28
  listState: () => broker.listState(),
29
29
  setActive: (label: string) => broker.setActive(label),
30
30
  markExhausted: (until?: number) => broker.markExhausted(until),
31
+ markThrottled: (until: number) => broker.markThrottled(until),
31
32
  rmAccount: (label: string) => broker.rmAccount(label),
32
33
  refreshAccount: (label: string) => broker.refreshAccount(label),
33
34
  setOverride: (agent: string, account: string | null) =>
@@ -301,6 +301,20 @@ export interface AuthBrokerClient {
301
301
  * identity — so auto-fallback works from any agent.
302
302
  */
303
303
  markExhausted(until?: number): Promise<{ account: string; rolled: string[]; rolledTo?: string | null }>
304
+ /**
305
+ * 429 throttle tier (broker `mark-throttled`). Records a transient
306
+ * per-account rate limit on the CALLER's own account — `throttled_until`
307
+ * in the quota ledger — WITHOUT rolling the fleet and WITHOUT touching
308
+ * eligibility. `escalated` is true when the broker's escalation guard
309
+ * (repeated hits corroborated by a live probe) converted it into the
310
+ * standard mark-exhausted + roll; `rolledTo` names the roll target then.
311
+ */
312
+ markThrottled(until: number): Promise<{
313
+ account: string
314
+ throttled_until: number
315
+ escalated: boolean
316
+ rolledTo?: string | null
317
+ }>
304
318
  rmAccount(label: string): Promise<{ label: string }>
305
319
  refreshAccount(label: string): Promise<{ account: string; expiresAt?: number }>
306
320
  setOverride(
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Pure helpers for forwarded-message origin metadata — kept out of
3
+ * `gateway.ts` (same pattern as `coalesce-attachments.ts`) so the parsing,
4
+ * escaping, and numbered-sibling logic can be unit-tested without the
5
+ * gateway's `loadAccess()` / IPC machinery.
6
+ *
7
+ * When a user forwards a message to the bot, Telegram stamps
8
+ * `message.forward_origin` SERVER-side (Bot API 7.0+). Unlike the message
9
+ * body — which the forwarding user fully controls — the origin record
10
+ * cannot be forged by typing, so it is the trusted lane for "who
11
+ * originally sent this". The gateway surfaces it to the agent as
12
+ * `forwarded_*` channel-meta ATTRIBUTES only; origin info is never
13
+ * injected into the body text, keeping the attacker-influenceable body
14
+ * and the server-stamped provenance on separate lanes.
15
+ *
16
+ * Caveat surfaced via `forwarded_from_type="hidden_user"`: a sender who
17
+ * enabled forward privacy yields only a self-reported display name with
18
+ * NO verifiable id — agents can see the name but must not treat it as an
19
+ * authenticated identity.
20
+ */
21
+
22
+ import type { MessageOrigin } from 'grammy/types'
23
+ import { escapeXmlAttribute } from '../steering.js'
24
+
25
+ /**
26
+ * Cap on the human-readable origin name/title. Names and titles are
27
+ * attacker-controlled (a hostile account can set a 4KB display name);
28
+ * mirror the REPLY_TO_TEXT_MAX truncate-then-escape pattern with a cap
29
+ * sized for names rather than message previews.
30
+ */
31
+ export const FORWARDED_FROM_NAME_MAX = 100
32
+
33
+ export type ForwardOriginType = 'user' | 'hidden_user' | 'chat' | 'channel'
34
+
35
+ /**
36
+ * Normalized origin record. `name` is RAW (truncated but unescaped) so it
37
+ * can be persisted verbatim to the SQLite history buffer — the XML
38
+ * escaping happens at the channel-meta boundary in
39
+ * `buildForwardOriginMeta`, same split as `replyToText` /
40
+ * `replyToTextEscaped` in the gateway inbound handler.
41
+ */
42
+ export interface ForwardOriginInfo {
43
+ /** Human-readable name/title of the original sender (raw, truncated). */
44
+ name: string
45
+ type: ForwardOriginType
46
+ /** Numeric id when the origin shape exposes one (user id / chat id). */
47
+ id?: number
48
+ /** Unix seconds of the original message (`forward_origin.date`). */
49
+ date?: number
50
+ /** Channel origins only: the message id inside the origin channel. */
51
+ messageId?: number
52
+ }
53
+
54
+ function truncateName(name: string): string {
55
+ return name.length > FORWARDED_FROM_NAME_MAX
56
+ ? name.slice(0, FORWARDED_FROM_NAME_MAX - 1) + '…'
57
+ : name
58
+ }
59
+
60
+ /** Join first/last name plus a trailing `(@username)` when present. */
61
+ function personName(parts: {
62
+ first_name?: string
63
+ last_name?: string
64
+ username?: string
65
+ }): string {
66
+ const name = [parts.first_name, parts.last_name]
67
+ .filter((p): p is string => typeof p === 'string' && p.length > 0)
68
+ .join(' ')
69
+ const handle = parts.username ? `(@${parts.username})` : ''
70
+ return [name, handle].filter((p) => p.length > 0).join(' ')
71
+ }
72
+
73
+ /** Chat/channel title plus a trailing `(@username)` when present. */
74
+ function chatTitle(chat: {
75
+ title?: string
76
+ username?: string
77
+ first_name?: string
78
+ last_name?: string
79
+ }): string {
80
+ // Defensive: `Chat` is a union — groups/channels carry `title`, a
81
+ // private chat carries first/last name instead. Fall through so a
82
+ // malformed/unexpected shape still yields whatever name exists.
83
+ if (chat.title && chat.title.length > 0) {
84
+ const handle = chat.username ? `(@${chat.username})` : ''
85
+ return [chat.title, handle].filter((p) => p.length > 0).join(' ')
86
+ }
87
+ return personName(chat)
88
+ }
89
+
90
+ /**
91
+ * Parse Telegram's `message.forward_origin` into a normalized record.
92
+ * Returns `undefined` for non-forwarded messages, unknown origin types,
93
+ * and records so malformed that no human-readable name can be recovered
94
+ * (the attrs are context, not a gate — degrading to "no origin metadata"
95
+ * is safe; inventing a name is not).
96
+ *
97
+ * Accepts `unknown`-ish input defensively: the origin arrives from the
98
+ * Telegram wire and future Bot API versions may add origin types this
99
+ * build doesn't know.
100
+ */
101
+ export function parseForwardOrigin(
102
+ origin: MessageOrigin | undefined,
103
+ ): ForwardOriginInfo | undefined {
104
+ if (origin == null || typeof origin !== 'object') return undefined
105
+ const date = typeof origin.date === 'number' ? origin.date : undefined
106
+ switch (origin.type) {
107
+ case 'user': {
108
+ const u = origin.sender_user
109
+ if (u == null || typeof u !== 'object') return undefined
110
+ const name = personName(u)
111
+ if (name.length === 0) {
112
+ // No printable name at all — fall back to the (unforgeable) id.
113
+ if (typeof u.id !== 'number') return undefined
114
+ return { name: String(u.id), type: 'user', id: u.id, date }
115
+ }
116
+ return {
117
+ name: truncateName(name),
118
+ type: 'user',
119
+ ...(typeof u.id === 'number' ? { id: u.id } : {}),
120
+ date,
121
+ }
122
+ }
123
+ case 'hidden_user': {
124
+ // Forward-privacy senders: `sender_user_name` is a SELF-REPORTED
125
+ // display name with no verifiable id. The `hidden_user` type marker
126
+ // is the agent's signal to treat the name as unauthenticated.
127
+ const name = typeof origin.sender_user_name === 'string'
128
+ ? origin.sender_user_name
129
+ : ''
130
+ if (name.length === 0) return undefined
131
+ return { name: truncateName(name), type: 'hidden_user', date }
132
+ }
133
+ case 'chat': {
134
+ const c = origin.sender_chat
135
+ if (c == null || typeof c !== 'object') return undefined
136
+ const name = chatTitle(c)
137
+ if (name.length === 0) {
138
+ if (typeof c.id !== 'number') return undefined
139
+ return { name: String(c.id), type: 'chat', id: c.id, date }
140
+ }
141
+ return {
142
+ name: truncateName(name),
143
+ type: 'chat',
144
+ ...(typeof c.id === 'number' ? { id: c.id } : {}),
145
+ date,
146
+ }
147
+ }
148
+ case 'channel': {
149
+ const c = origin.chat
150
+ if (c == null || typeof c !== 'object') return undefined
151
+ const name = chatTitle(c)
152
+ const messageId = typeof origin.message_id === 'number' ? origin.message_id : undefined
153
+ if (name.length === 0) {
154
+ if (typeof c.id !== 'number') return undefined
155
+ return { name: String(c.id), type: 'channel', id: c.id, date, messageId }
156
+ }
157
+ return {
158
+ name: truncateName(name),
159
+ type: 'channel',
160
+ ...(typeof c.id === 'number' ? { id: c.id } : {}),
161
+ date,
162
+ messageId,
163
+ }
164
+ }
165
+ default:
166
+ // Unknown future origin type — no metadata beats wrong metadata.
167
+ return undefined
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Identity key for burst dedup: same-typed origins with the same id are
173
+ * one origin; id-less shapes (hidden_user) fall back to the name.
174
+ */
175
+ export function forwardOriginKey(o: ForwardOriginInfo): string {
176
+ return o.id != null ? `${o.type}:${o.id}` : `${o.type}:${o.name}`
177
+ }
178
+
179
+ /**
180
+ * Collapse the per-message origins of a coalesced burst into the DISTINCT
181
+ * origins in arrival order. A 10-part album forwarded from one channel
182
+ * yields ONE origin (attrs emitted once); a burst mixing forwards from two
183
+ * different senders yields two (the second gets `forwarded_from_2` etc.).
184
+ * Non-forwarded entries (`undefined`) are skipped. First occurrence wins,
185
+ * so the emitted `forwarded_date` is the first message's origin date.
186
+ */
187
+ export function dedupeForwardOrigins(
188
+ origins: Array<ForwardOriginInfo | undefined>,
189
+ ): ForwardOriginInfo[] {
190
+ const seen = new Set<string>()
191
+ const out: ForwardOriginInfo[] = []
192
+ for (const o of origins) {
193
+ if (o == null) continue
194
+ const key = forwardOriginKey(o)
195
+ if (seen.has(key)) continue
196
+ seen.add(key)
197
+ out.push(o)
198
+ }
199
+ return out
200
+ }
201
+
202
+ /**
203
+ * Build the `forwarded_*` channel-meta fields. Fixed per-origin attribute
204
+ * order (documented here, tested in forward-origin.test.ts):
205
+ * forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date
206
+ * The primary field is the human-readable NAME; the numeric id is
207
+ * supplementary and follows it. The first origin gets the bare keys;
208
+ * subsequent distinct origins get `_2`, `_3`, … suffixes — the same
209
+ * numbered-sibling convention as `image_path_2` / `attachment_file_id_2`.
210
+ *
211
+ * Names/titles are attacker-controlled, so every value that can carry
212
+ * user text goes through `escapeXmlAttribute` (the same escaper
213
+ * `formatReplyToText` uses) before landing in the channel tag.
214
+ */
215
+ export function buildForwardOriginMeta(
216
+ origins: ForwardOriginInfo[],
217
+ ): Record<string, string> {
218
+ const out: Record<string, string> = {}
219
+ origins.forEach((o, i) => {
220
+ const suffix = i === 0 ? '' : `_${i + 1}`
221
+ out[`forwarded_from${suffix}`] = escapeXmlAttribute(truncateName(o.name))
222
+ out[`forwarded_from_type${suffix}`] = o.type
223
+ if (o.id != null) out[`forwarded_from_id${suffix}`] = String(o.id)
224
+ if (o.date != null) {
225
+ out[`forwarded_date${suffix}`] = new Date(o.date * 1000).toISOString()
226
+ }
227
+ })
228
+ return out
229
+ }
230
+
231
+ /** ISO form of an origin date for the SQLite history record. */
232
+ export function forwardOriginDateIso(o: ForwardOriginInfo | undefined): string | null {
233
+ if (o?.date == null) return null
234
+ return new Date(o.date * 1000).toISOString()
235
+ }