switchroom 0.20.22 → 0.21.1

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.
@@ -0,0 +1,418 @@
1
+ /**
2
+ * system-message-observer.ts — make CARD message ids resolvable (#4571).
3
+ *
4
+ * The problem
5
+ * -----------
6
+ * Only two things ever reached `history.db`: an inbound message, and an
7
+ * outbound that flowed through the `reply` / `stream_reply` family (which call
8
+ * `recordOutbound` explicitly). Everything else the gateway posts — the
9
+ * mid-turn activity card, the pinned status message, approval / boot / issues
10
+ * / worker-feed cards, `progress_update` lines, restart notices — consumed a
11
+ * real Telegram message id and left NO row behind. Measured on a live agent's
12
+ * buffer: 116 rows across a 266-id span above id 20000, i.e. ~56% of the ids
13
+ * in that chat were absent, clustered exactly where the cards were.
14
+ *
15
+ * That is user-visible, not cosmetic. The activity card is the most recent
16
+ * message on the operator's screen for most of a turn, so quote-replying to it
17
+ * is the natural gesture. Telegram then delivers `reply_to_message_id` pointing
18
+ * at a message the agent has no record of, the reply-antecedent resolver
19
+ * (`resolveReplyToFromBuffer`) gets `null` back, and the agent has to say "I
20
+ * can't see the message you replied to".
21
+ *
22
+ * The mechanism
23
+ * -------------
24
+ * Rather than add a `recordSystemOutbound(...)` call to each of the ~110 raw
25
+ * send sites (which is exactly the kind of per-call-site discipline that
26
+ * decays — the `reply` path was the only site anyone remembered), this hooks
27
+ * the ONE chokepoint every gateway outbound already goes through:
28
+ * `gateway.ts`'s `robustApiCall` (chat-lock → send-gate → retry policy). Every
29
+ * card send in the gateway is routed through it, enforced by the
30
+ * `check-bot-api-wrapping` lint guard.
31
+ *
32
+ * The observer reads the Telegram RESPONSE, which buys three things for free:
33
+ * - the real `message_id` (the only thing a reply can point at),
34
+ * - the chat and forum-topic the message actually landed in,
35
+ * - the BODY, as Telegram RENDERED it.
36
+ *
37
+ * The #4576 bug was reading only ONE body field off the response. Every card
38
+ * goes out via Bot API 10.1 `sendRichMessage`, and the `Message` it returns is
39
+ * a `Message.RichMessageMessage`: the body lives under `rich_message.blocks`
40
+ * ("Content of the message" — Bot API `RichMessage`), and `text` / `caption`
41
+ * are absent. `extractSentMessage` read `msg.text ?? msg.caption ?? ''` and so
42
+ * stored `''` on 100% of the rows on every agent in the fleet — the agent could
43
+ * see WHICH card was quote-replied to but never WHAT IT SAID. The fix is to
44
+ * read `rich_message` too, flattened by the same renderer the inbound
45
+ * rich-message handler uses.
46
+ *
47
+ * Preferring the response is not just simpler, it is more FAITHFUL. The
48
+ * request-side body for the dominant card path has already been through
49
+ * `richMessage()`'s `guardAccidentalFormatting` (`rich-send.ts`), which is
50
+ * applied in the CALLER, so `sent_text_capture.ts` is on the wire as
51
+ * `sent\_text\_capture.ts` and `$12.40` as `\$12.40`. The response's
52
+ * `rich_message` is the parsed, rendered block tree with those escapes already
53
+ * resolved — i.e. what the operator actually saw on screen, which is exactly
54
+ * what a quote-reply antecedent should say.
55
+ *
56
+ * The request-side stamp (`shared/sent-text-capture.ts`) is kept, LAST in the
57
+ * precedence order, as the fallback for the shapes a response cannot supply —
58
+ * a rich send whose blocks render to nothing (a media-only card), or a future
59
+ * verb whose response omits the body. It is never preferred over the response.
60
+ *
61
+ * Send vs edit is NOT guessed from the verb (verb tagging is not uniform
62
+ * across call sites and would rot). It falls out of the data: an edit returns
63
+ * the same `message_id` it edited, so the conditional insert no-ops and the
64
+ * call falls through to an in-place text refresh. One row per card, forever,
65
+ * regardless of how many times it is edited.
66
+ *
67
+ * Cost control. The activity card is the highest-volume repeated
68
+ * `editMessageText` in the gateway (it climbs every few seconds for the whole
69
+ * turn). Refreshing its stored text on every edit would be a SQLite write per
70
+ * edit. So a per-message throttle (`editRefreshMs`, default 20s) keeps the hot
71
+ * path entirely in memory: a card edit inside the window costs one Map lookup
72
+ * and no DB work at all. The stored text is therefore a recent snapshot, not
73
+ * a byte-exact mirror of the live card — which is the right trade for a
74
+ * quote-reply antecedent.
75
+ *
76
+ * Nothing here throws. A failure to record a card must never break the send it
77
+ * is observing.
78
+ */
79
+
80
+ import { readSentText } from '../shared/sent-text-capture.js'
81
+ import { extractRichMessageText } from './rich-message-handler.js'
82
+
83
+ /** The subset of a Telegram `Message` response this observer reads. */
84
+ export interface SentMessageLike {
85
+ message_id?: unknown
86
+ chat?: { id?: unknown } | null
87
+ message_thread_id?: unknown
88
+ text?: unknown
89
+ caption?: unknown
90
+ rich_message?: unknown
91
+ }
92
+
93
+ /** The subset of `robustApiCall`'s opts the observer reads. */
94
+ export interface ObservedCallOpts {
95
+ chat_id?: string
96
+ threadId?: number
97
+ verb?: string
98
+ }
99
+
100
+ export interface SystemMessageObserverDeps {
101
+ /**
102
+ * `history.recordSystemOutbound`. Must return true iff it inserted a NEW
103
+ * row, and false if any row already existed for (chat_id, message_id).
104
+ */
105
+ insert: (args: {
106
+ chat_id: string
107
+ thread_id: number | null
108
+ message_id: number
109
+ kind: string | null
110
+ text: string
111
+ }) => boolean
112
+ /**
113
+ * `history.updateSystemOutboundText`. Must return true iff it updated a row,
114
+ * and false when the target row is absent or is NOT a system row (i.e. the
115
+ * id belongs to a real reply or an inbound).
116
+ */
117
+ updateText: (args: { chat_id: string; message_id: number; text: string }) => boolean
118
+ /** Injectable clock for the edit throttle. Defaults to `Date.now`. */
119
+ now?: () => number
120
+ /**
121
+ * Called when a card row is about to be written with an EMPTY body — i.e.
122
+ * neither the response nor the request-side stamp yielded anything readable.
123
+ *
124
+ * This is the alarm for the #4576 failure mode. That bug was silent for a
125
+ * whole release precisely because an empty body is indistinguishable from a
126
+ * healthy row unless someone queries `length(text)`. Fired at most ONCE per
127
+ * `kind` (else per raw verb) per process so a broken verb is loud in the log
128
+ * without becoming a per-send stderr storm. Never called with a non-empty
129
+ * body, and never for a response that is legitimately bodiless
130
+ * (`isLegitimatelyBodiless`) — an alarm on `sendSticker` is noise a reader
131
+ * cannot act on, and noise is what teaches people to ignore the alarm.
132
+ *
133
+ * Defaults to `defaultEmptyCardTextWarning` (stderr). Pass an explicit
134
+ * function to redirect it, or `() => {}` to silence it in a test.
135
+ */
136
+ onEmptyText?: (info: { chat_id: string; message_id: number; kind: string | null }) => void
137
+ }
138
+
139
+ export interface SystemMessageObserverOptions {
140
+ /**
141
+ * Minimum gap between two stored-text refreshes of the SAME message. Edits
142
+ * inside the window are dropped without touching SQLite.
143
+ */
144
+ editRefreshMs?: number
145
+ /** Cap on tracked message ids before the oldest half is evicted. */
146
+ maxTracked?: number
147
+ }
148
+
149
+ export const DEFAULT_EDIT_REFRESH_MS = 20_000
150
+ export const DEFAULT_MAX_TRACKED = 512
151
+
152
+ /**
153
+ * Normalise a `robustApiCall` verb into the stored `kind` discriminator.
154
+ *
155
+ * The verb is the honest, already-present label for what a send IS
156
+ * (`activity-summary.send`, `boot-card`, `worker-feed`, `approval-card`), so
157
+ * the kind is derived rather than invented. The trailing transport suffix is
158
+ * stripped so a card's OPEN and its EDITs classify identically.
159
+ *
160
+ * Pure. Returns null for an absent / blank verb.
161
+ */
162
+ export function normalizeSendVerb(verb: string | undefined | null): string | null {
163
+ if (typeof verb !== 'string') return null
164
+ const trimmed = verb.trim()
165
+ if (trimmed.length === 0) return null
166
+ const base = trimmed.replace(/\.(send|edit|create|post|update)$/i, '')
167
+ const cleaned = (base.length > 0 ? base : trimmed).slice(0, 64)
168
+ return cleaned.length > 0 ? cleaned : null
169
+ }
170
+
171
+ /**
172
+ * Extract the (chat_id, message_id, thread, text) tuple from a Telegram API
173
+ * result, or null when the result is not a sent/edited Message (the retry
174
+ * wrapper also returns `true` for pins / deletes / reactions / callback
175
+ * answers, and `undefined` for a swallowed benign 400).
176
+ *
177
+ * The response's own `chat.id` wins over the caller's `chat_id` opt: it is what
178
+ * Telegram actually delivered to, and several call sites pass no `chat_id` at
179
+ * all.
180
+ *
181
+ * `text` is resolved in strict precedence order, most authoritative first:
182
+ * 1. `rich_message` on the RESPONSE, flattened by the same renderer the
183
+ * inbound rich-message handler uses. This is Telegram's own rendering of
184
+ * the block tree, so markdown escapes are already resolved — it is what
185
+ * the operator saw, and it is the shape every card send returns;
186
+ * 2. `text` / `caption`, the plain-send and media-caption response shapes;
187
+ * 3. the body stamped by `installSentTextCapture` from the outbound REQUEST —
188
+ * LAST, because on the dominant card path (`richMessage(body)`) it is the
189
+ * guard-escaped wire form, not the body as written. It is the fallback for
190
+ * responses that carry no renderable body at all.
191
+ * `''` only when all three are absent, which the observer treats as an alarm
192
+ * unless the response is legitimately bodiless. Pure.
193
+ */
194
+ export function extractSentMessage(
195
+ result: unknown,
196
+ opts?: ObservedCallOpts,
197
+ ): { chatId: string; messageId: number; threadId: number | null; text: string } | null {
198
+ if (result == null || typeof result !== 'object') return null
199
+ const msg = result as SentMessageLike
200
+ const id = msg.message_id
201
+ if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) return null
202
+ const rawChat = msg.chat?.id
203
+ const chatId =
204
+ rawChat != null && (typeof rawChat === 'number' || typeof rawChat === 'string')
205
+ ? String(rawChat)
206
+ : opts?.chat_id
207
+ if (chatId == null || chatId.length === 0) return null
208
+ const rawThread = msg.message_thread_id
209
+ const threadId =
210
+ typeof rawThread === 'number' && Number.isInteger(rawThread)
211
+ ? rawThread
212
+ : typeof opts?.threadId === 'number'
213
+ ? opts.threadId
214
+ : null
215
+ const text =
216
+ (msg.rich_message != null ? extractRichMessageText(msg.rich_message) : undefined) ??
217
+ nonEmptyString(msg.text) ??
218
+ nonEmptyString(msg.caption) ??
219
+ readSentText(result) ??
220
+ ''
221
+ return { chatId, messageId: id, threadId, text }
222
+ }
223
+
224
+ /** `v` when it is a non-empty string, else undefined — so an empty `text` on
225
+ * the response falls THROUGH to the next precedence tier instead of pinning
226
+ * the result to `''`. */
227
+ function nonEmptyString(v: unknown): string | undefined {
228
+ return typeof v === 'string' && v.length > 0 ? v : undefined
229
+ }
230
+
231
+ /**
232
+ * Response keys that mark a Telegram `Message` as LEGITIMATELY bodiless: the
233
+ * send verb that produced it has no user-visible text by construction.
234
+ *
235
+ * The empty-body alarm exists to make a recurrence of #4576 loud. It is only
236
+ * useful if it fires on a REGRESSION, so the verbs that are *supposed* to
237
+ * store an empty body — `sendSticker`, `sendAnimation`, `sendVoice`,
238
+ * `forwardMessage` of a media message, an uncaptioned `sendPhoto` — must not
239
+ * emit an alarm indistinguishable from one.
240
+ *
241
+ * Keyed on the RESPONSE SHAPE rather than on `opts.verb` deliberately: verb
242
+ * tagging is not uniform across call sites (`forwardMessage`,
243
+ * `gateway.ts`, passes none at all), so a verb allowlist would rot exactly
244
+ * where this needs to hold. A regressed CARD is a `rich_message` response
245
+ * whose blocks rendered to nothing — it carries none of these keys and still
246
+ * alarms. Pure.
247
+ */
248
+ const BODILESS_MESSAGE_KEYS = [
249
+ 'sticker', 'animation', 'voice', 'video_note', 'dice', 'game', 'poll',
250
+ 'contact', 'location', 'venue', 'story', 'invoice', 'successful_payment',
251
+ 'checklist', 'photo', 'video', 'audio', 'document', 'paid_media',
252
+ ] as const
253
+
254
+ export function isLegitimatelyBodiless(result: unknown): boolean {
255
+ if (result == null || typeof result !== 'object') return false
256
+ const r = result as Record<string, unknown>
257
+ return BODILESS_MESSAGE_KEYS.some((k) => r[k] != null)
258
+ }
259
+
260
+ /**
261
+ * The observer's DEFAULT empty-body alarm: one stderr line naming the card kind
262
+ * whose text capture missed.
263
+ *
264
+ * It lives here, and is the default rather than something gateway.ts wires,
265
+ * for two reasons: gateway.ts is under an anti-inflation line ratchet
266
+ * (switchroom#2996) so new logic belongs in a module; and a caller that forgets
267
+ * to pass `onEmptyText` is exactly the caller that would re-ship #4576
268
+ * silently. Opting OUT is now the explicit act.
269
+ */
270
+ export function defaultEmptyCardTextWarning(info: {
271
+ chat_id: string
272
+ message_id: number
273
+ kind: string | null
274
+ }): void {
275
+ try {
276
+ process.stderr.write(
277
+ `telegram gateway: card-history text capture MISSED kind=${info.kind ?? '-'} ` +
278
+ `chat=${info.chat_id} id=${info.message_id} — the response carried no ` +
279
+ `rich_message/text/caption and no request-side body was stamped, so a ` +
280
+ `quote-reply to this card will resolve its kind but not its body ` +
281
+ `(see gateway/system-message-observer.ts extractSentMessage)\n`,
282
+ )
283
+ } catch {
284
+ /* a broken stderr must never break the send */
285
+ }
286
+ }
287
+
288
+ /** Per-id bookkeeping. `foreign` = the id belongs to a non-system row (a real
289
+ * reply or an inbound); never write to it again. `storedLen` is the length of
290
+ * the body currently in the row — 0 means the row is a HOLE, which the edit
291
+ * throttle must not preserve. */
292
+ type TrackedState = { lane: 'system' | 'foreign'; lastStoredMs: number; storedLen: number }
293
+
294
+ /**
295
+ * Build the observer. The returned function is called with the RESOLVED result
296
+ * of every `robustApiCall` and never throws.
297
+ */
298
+ export function makeSystemMessageObserver(
299
+ deps: SystemMessageObserverDeps,
300
+ options?: SystemMessageObserverOptions,
301
+ ): (result: unknown, opts?: ObservedCallOpts) => void {
302
+ const now = deps.now ?? Date.now
303
+ const editRefreshMs = options?.editRefreshMs ?? DEFAULT_EDIT_REFRESH_MS
304
+ const maxTracked = Math.max(1, options?.maxTracked ?? DEFAULT_MAX_TRACKED)
305
+ const tracked = new Map<string, TrackedState>()
306
+ /** Kinds already reported through `onEmptyText` — one alarm per kind, per process. */
307
+ const emptyReported = new Set<string>()
308
+ const onEmptyText = deps.onEmptyText ?? defaultEmptyCardTextWarning
309
+
310
+ function reportEmpty(
311
+ chatId: string,
312
+ messageId: number,
313
+ kind: string | null,
314
+ verb: string | undefined,
315
+ ): void {
316
+ // Bucket on the kind, else the RAW verb, else the untagged catch-all. Using
317
+ // `kind ?? '<untagged>'` alone collapsed every untagged verb into one
318
+ // bucket, so the first bodiless untagged send in the process permanently
319
+ // silenced the alarm for every other untagged verb — including a real
320
+ // regression.
321
+ const bucket = kind ?? (typeof verb === 'string' && verb.length > 0 ? verb : '<untagged>')
322
+ if (emptyReported.has(bucket)) return
323
+ emptyReported.add(bucket)
324
+ try {
325
+ onEmptyText({ chat_id: chatId, message_id: messageId, kind })
326
+ } catch {
327
+ /* the alarm must never break the send either */
328
+ }
329
+ }
330
+
331
+ function remember(key: string, state: TrackedState): void {
332
+ tracked.set(key, state)
333
+ if (tracked.size > maxTracked) {
334
+ // Map iterates in insertion order — drop the oldest half in one pass so
335
+ // eviction is amortised O(1) rather than per-insert.
336
+ const drop = Math.ceil(tracked.size / 2)
337
+ let i = 0
338
+ for (const k of tracked.keys()) {
339
+ if (i++ >= drop) break
340
+ tracked.delete(k)
341
+ }
342
+ }
343
+ }
344
+
345
+ return function observeSentMessage(result: unknown, opts?: ObservedCallOpts): void {
346
+ try {
347
+ const sent = extractSentMessage(result, opts)
348
+ if (sent == null) return
349
+ const key = `${sent.chatId}:${sent.messageId}`
350
+ const t = now()
351
+ const seen = tracked.get(key)
352
+
353
+ const kind = normalizeSendVerb(opts?.verb)
354
+ if (sent.text.length === 0 && !isLegitimatelyBodiless(result)) {
355
+ reportEmpty(sent.chatId, sent.messageId, kind, opts?.verb)
356
+ }
357
+
358
+ if (seen != null) {
359
+ if (seen.lane === 'foreign') return
360
+ // Never blank a body we already stored. A refresh whose text did not
361
+ // reach us is missing information, not new information — overwriting
362
+ // with `''` would destroy a usable quote-reply antecedent and hand the
363
+ // agent the #4576 symptom on a row that was healthy a moment ago.
364
+ if (sent.text.length === 0) return
365
+ // The dual rule, and the one whose absence kept the #4576 symptom alive
366
+ // on a row the alarm had already given up on: ALWAYS fill an EMPTY
367
+ // stored body. A bodiless first observation (a media-only card, a
368
+ // response we could not read) inserts `''` and starts the throttle
369
+ // clock; the first REAL body then lands inside the 20s window and was
370
+ // dropped, leaving the row permanently unusable as a quote-reply
371
+ // antecedent. The throttle exists to cap SQLite writes on a card that
372
+ // is already READABLE, so it only applies once something is stored.
373
+ if (seen.storedLen > 0 && t - seen.lastStoredMs < editRefreshMs) return
374
+ if (deps.updateText({ chat_id: sent.chatId, message_id: sent.messageId, text: sent.text })) {
375
+ seen.lastStoredMs = t
376
+ seen.storedLen = sent.text.length
377
+ } else {
378
+ // The row is gone (retention prune / delete) or was promoted to a
379
+ // real `assistant` reply by recordOutbound. Either way this id is no
380
+ // longer ours to write.
381
+ seen.lane = 'foreign'
382
+ }
383
+ return
384
+ }
385
+
386
+ const inserted = deps.insert({
387
+ chat_id: sent.chatId,
388
+ thread_id: sent.threadId,
389
+ message_id: sent.messageId,
390
+ kind,
391
+ text: sent.text,
392
+ })
393
+ if (inserted) {
394
+ remember(key, { lane: 'system', lastStoredMs: t, storedLen: sent.text.length })
395
+ return
396
+ }
397
+ // A row already exists for this id and we did not create it in this
398
+ // process: either a real reply / inbound (leave it alone), or a card this
399
+ // gateway posted before a restart. One probing update disambiguates —
400
+ // `updateText` only ever matches a `system` row. The probe WRITES, so an
401
+ // empty body cannot be used to run it: skip, stay untracked, and let the
402
+ // next observation of this id (which carries a body) do the probing.
403
+ if (sent.text.length === 0) return
404
+ const refreshed = deps.updateText({
405
+ chat_id: sent.chatId,
406
+ message_id: sent.messageId,
407
+ text: sent.text,
408
+ })
409
+ remember(key, {
410
+ lane: refreshed ? 'system' : 'foreign',
411
+ lastStoredMs: t,
412
+ storedLen: refreshed ? sent.text.length : 0,
413
+ })
414
+ } catch {
415
+ /* observing a send must never break the send */
416
+ }
417
+ }
418
+ }
@@ -38,15 +38,14 @@
38
38
  import {
39
39
  closeSync,
40
40
  existsSync,
41
- mkdirSync,
42
41
  openSync,
43
42
  readFileSync,
44
43
  statSync,
45
44
  unlinkSync,
46
45
  utimesSync,
47
- writeFileSync,
48
46
  } from "node:fs";
49
47
  import { join } from "node:path";
48
+ import { mkdirStateSync, writeStateFileSync } from "../../src/util/state-owner.js";
50
49
 
51
50
  export const TURN_ACTIVE_MARKER_FILE = "turn-active.json";
52
51
 
@@ -82,8 +81,8 @@ export interface TurnActiveMarker {
82
81
  */
83
82
  export function writeTurnActiveMarker(stateDir: string, marker: TurnActiveMarker): void {
84
83
  try {
85
- mkdirSync(stateDir, { recursive: true });
86
- writeFileSync(
84
+ mkdirStateSync(stateDir, { recursive: true });
85
+ writeStateFileSync(
87
86
  join(stateDir, TURN_ACTIVE_MARKER_FILE),
88
87
  JSON.stringify(marker, null, 2) + "\n",
89
88
  { mode: 0o600 },