switchroom 0.21.0 → 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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.21.0", COMMIT_SHA = "d8bd2d30";
2123
+ var VERSION = "0.21.1", COMMIT_SHA = "6aef7b24";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -21565,7 +21565,7 @@ function allocateAgentUid(name) {
21565
21565
  }
21566
21566
 
21567
21567
  // src/build-info.ts
21568
- var VERSION = "0.21.0";
21568
+ var VERSION = "0.21.1";
21569
21569
 
21570
21570
  // src/setup/hindsight-recall-passthrough.ts
21571
21571
  var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.21.0",
4
+ "version": "0.21.1",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -67137,6 +67137,83 @@ function installRichMarkdownGuard(bot) {
67137
67137
  });
67138
67138
  }
67139
67139
 
67140
+ // shared/sent-text-capture.ts
67141
+ var SENT_TEXT = Symbol.for("switchroom.telegram.sentText");
67142
+ var MAX_BLOCK_DEPTH = 8;
67143
+ function flattenInputRichBlocks(blocks, depth) {
67144
+ if (!Array.isArray(blocks) || depth > MAX_BLOCK_DEPTH)
67145
+ return "";
67146
+ const parts = [];
67147
+ for (const block of blocks) {
67148
+ if (block == null || typeof block !== "object")
67149
+ continue;
67150
+ const b = block;
67151
+ for (const key of ["markdown", "html", "text", "caption"]) {
67152
+ const v = b[key];
67153
+ if (typeof v === "string" && v.length > 0)
67154
+ parts.push(v);
67155
+ }
67156
+ const nested = flattenInputRichBlocks(b.blocks, depth + 1);
67157
+ if (nested.length > 0)
67158
+ parts.push(nested);
67159
+ }
67160
+ return parts.join(`
67161
+ `);
67162
+ }
67163
+ function outboundPayloadText(payload) {
67164
+ if (payload == null || typeof payload !== "object")
67165
+ return null;
67166
+ const p = payload;
67167
+ const rich = p.rich_message;
67168
+ if (rich != null && typeof rich === "object") {
67169
+ const r = rich;
67170
+ if (typeof r.markdown === "string")
67171
+ return r.markdown;
67172
+ if (typeof r.html === "string")
67173
+ return r.html;
67174
+ const flat = flattenInputRichBlocks(r.blocks, 0);
67175
+ if (flat.length > 0)
67176
+ return flat;
67177
+ }
67178
+ if (typeof p.text === "string")
67179
+ return p.text;
67180
+ if (typeof p.caption === "string")
67181
+ return p.caption;
67182
+ return null;
67183
+ }
67184
+ function attachSentText(envelope2, text4) {
67185
+ try {
67186
+ if (envelope2 == null || typeof envelope2 !== "object")
67187
+ return;
67188
+ const env = envelope2;
67189
+ if (env.ok !== true)
67190
+ return;
67191
+ const result = env.result;
67192
+ if (result == null || typeof result !== "object" || Array.isArray(result))
67193
+ return;
67194
+ Object.defineProperty(result, SENT_TEXT, {
67195
+ value: text4,
67196
+ enumerable: false,
67197
+ configurable: true,
67198
+ writable: true
67199
+ });
67200
+ } catch {}
67201
+ }
67202
+ function installSentTextCapture(bot) {
67203
+ bot.api.config.use(async (prev, method, payload, signal) => {
67204
+ let text4 = null;
67205
+ try {
67206
+ text4 = outboundPayloadText(payload);
67207
+ } catch {
67208
+ text4 = null;
67209
+ }
67210
+ const res = await prev(method, payload, signal);
67211
+ if (text4 != null)
67212
+ attachSentText(res, text4);
67213
+ return res;
67214
+ });
67215
+ }
67216
+
67140
67217
  // flood-circuit-breaker.ts
67141
67218
  init_flood_429_ledger();
67142
67219
  import {
@@ -76389,6 +76466,15 @@ function query(opts) {
76389
76466
  return rows;
76390
76467
  }
76391
76468
 
76469
+ // shared/sent-text-capture.ts
76470
+ var SENT_TEXT2 = Symbol.for("switchroom.telegram.sentText");
76471
+ function readSentText(message) {
76472
+ if (message == null || typeof message !== "object")
76473
+ return null;
76474
+ const v = message[SENT_TEXT2];
76475
+ return typeof v === "string" ? v : null;
76476
+ }
76477
+
76392
76478
  // gateway/system-message-observer.ts
76393
76479
  var DEFAULT_EDIT_REFRESH_MS = 20000;
76394
76480
  var DEFAULT_MAX_TRACKED = 512;
@@ -76415,14 +76501,61 @@ function extractSentMessage(result, opts) {
76415
76501
  return null;
76416
76502
  const rawThread = msg.message_thread_id;
76417
76503
  const threadId = typeof rawThread === "number" && Number.isInteger(rawThread) ? rawThread : typeof opts?.threadId === "number" ? opts.threadId : null;
76418
- const text4 = typeof msg.text === "string" ? msg.text : typeof msg.caption === "string" ? msg.caption : "";
76504
+ const text4 = (msg.rich_message != null ? extractRichMessageText(msg.rich_message) : undefined) ?? nonEmptyString(msg.text) ?? nonEmptyString(msg.caption) ?? readSentText(result) ?? "";
76419
76505
  return { chatId, messageId: id, threadId, text: text4 };
76420
76506
  }
76507
+ function nonEmptyString(v) {
76508
+ return typeof v === "string" && v.length > 0 ? v : undefined;
76509
+ }
76510
+ var BODILESS_MESSAGE_KEYS = [
76511
+ "sticker",
76512
+ "animation",
76513
+ "voice",
76514
+ "video_note",
76515
+ "dice",
76516
+ "game",
76517
+ "poll",
76518
+ "contact",
76519
+ "location",
76520
+ "venue",
76521
+ "story",
76522
+ "invoice",
76523
+ "successful_payment",
76524
+ "checklist",
76525
+ "photo",
76526
+ "video",
76527
+ "audio",
76528
+ "document",
76529
+ "paid_media"
76530
+ ];
76531
+ function isLegitimatelyBodiless(result) {
76532
+ if (result == null || typeof result !== "object")
76533
+ return false;
76534
+ const r = result;
76535
+ return BODILESS_MESSAGE_KEYS.some((k) => r[k] != null);
76536
+ }
76537
+ function defaultEmptyCardTextWarning(info) {
76538
+ try {
76539
+ process.stderr.write(`telegram gateway: card-history text capture MISSED kind=${info.kind ?? "-"} ` + `chat=${info.chat_id} id=${info.message_id} \u2014 the response carried no ` + `rich_message/text/caption and no request-side body was stamped, so a ` + `quote-reply to this card will resolve its kind but not its body ` + `(see gateway/system-message-observer.ts extractSentMessage)
76540
+ `);
76541
+ } catch {}
76542
+ }
76421
76543
  function makeSystemMessageObserver(deps, options) {
76422
76544
  const now = deps.now ?? Date.now;
76423
76545
  const editRefreshMs = options?.editRefreshMs ?? DEFAULT_EDIT_REFRESH_MS;
76424
76546
  const maxTracked = Math.max(1, options?.maxTracked ?? DEFAULT_MAX_TRACKED);
76425
76547
  const tracked = new Map;
76548
+ const emptyReported = new Set;
76549
+ const onEmptyText = deps.onEmptyText ?? defaultEmptyCardTextWarning;
76550
+ function reportEmpty(chatId, messageId, kind, verb) {
76551
+ const bucket = kind ?? (typeof verb === "string" && verb.length > 0 ? verb : "<untagged>");
76552
+ if (emptyReported.has(bucket))
76553
+ return;
76554
+ emptyReported.add(bucket);
76555
+ try {
76556
+ onEmptyText({ chat_id: chatId, message_id: messageId, kind });
76557
+ } catch {}
76558
+ }
76426
76559
  function remember(key, state3) {
76427
76560
  tracked.set(key, state3);
76428
76561
  if (tracked.size > maxTracked) {
@@ -76443,13 +76576,20 @@ function makeSystemMessageObserver(deps, options) {
76443
76576
  const key = `${sent.chatId}:${sent.messageId}`;
76444
76577
  const t = now();
76445
76578
  const seen = tracked.get(key);
76579
+ const kind = normalizeSendVerb(opts?.verb);
76580
+ if (sent.text.length === 0 && !isLegitimatelyBodiless(result)) {
76581
+ reportEmpty(sent.chatId, sent.messageId, kind, opts?.verb);
76582
+ }
76446
76583
  if (seen != null) {
76447
76584
  if (seen.lane === "foreign")
76448
76585
  return;
76449
- if (t - seen.lastStoredMs < editRefreshMs)
76586
+ if (sent.text.length === 0)
76587
+ return;
76588
+ if (seen.storedLen > 0 && t - seen.lastStoredMs < editRefreshMs)
76450
76589
  return;
76451
76590
  if (deps.updateText({ chat_id: sent.chatId, message_id: sent.messageId, text: sent.text })) {
76452
76591
  seen.lastStoredMs = t;
76592
+ seen.storedLen = sent.text.length;
76453
76593
  } else {
76454
76594
  seen.lane = "foreign";
76455
76595
  }
@@ -76459,19 +76599,25 @@ function makeSystemMessageObserver(deps, options) {
76459
76599
  chat_id: sent.chatId,
76460
76600
  thread_id: sent.threadId,
76461
76601
  message_id: sent.messageId,
76462
- kind: normalizeSendVerb(opts?.verb),
76602
+ kind,
76463
76603
  text: sent.text
76464
76604
  });
76465
76605
  if (inserted) {
76466
- remember(key, { lane: "system", lastStoredMs: t });
76606
+ remember(key, { lane: "system", lastStoredMs: t, storedLen: sent.text.length });
76467
76607
  return;
76468
76608
  }
76609
+ if (sent.text.length === 0)
76610
+ return;
76469
76611
  const refreshed = deps.updateText({
76470
76612
  chat_id: sent.chatId,
76471
76613
  message_id: sent.messageId,
76472
76614
  text: sent.text
76473
76615
  });
76474
- remember(key, { lane: refreshed ? "system" : "foreign", lastStoredMs: t });
76616
+ remember(key, {
76617
+ lane: refreshed ? "system" : "foreign",
76618
+ lastStoredMs: t,
76619
+ storedLen: refreshed ? sent.text.length : 0
76620
+ });
76475
76621
  } catch {}
76476
76622
  };
76477
76623
  }
@@ -104023,10 +104169,10 @@ function startOutboxSweep(deps) {
104023
104169
  }
104024
104170
 
104025
104171
  // ../src/build-info.ts
104026
- var VERSION2 = "0.21.0";
104027
- var COMMIT_SHA = "d8bd2d30";
104028
- var COMMIT_DATE = "2026-08-10T00:05:28Z";
104029
- var LATEST_PR = 4582;
104172
+ var VERSION2 = "0.21.1";
104173
+ var COMMIT_SHA = "6aef7b24";
104174
+ var COMMIT_DATE = "2026-08-10T02:48:34Z";
104175
+ var LATEST_PR = 4583;
104030
104176
  var COMMITS_AHEAD_OF_TAG = 0;
104031
104177
 
104032
104178
  // gateway/boot-version.ts
@@ -117251,6 +117397,7 @@ async function initGatewayBot() {
117251
117397
  bot = new import_grammy17.Bot(TOKEN);
117252
117398
  installTgPostLogger(bot);
117253
117399
  installRichMarkdownGuard(bot);
117400
+ installSentTextCapture(bot);
117254
117401
  installEditFloodFuse(bot, {
117255
117402
  ...editFloodFuseConfigFromEnv(process.env),
117256
117403
  onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key} class=${i.cls}
@@ -303,6 +303,7 @@ import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-
303
303
  import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
304
304
  import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
305
305
  import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
306
+ import { installSentTextCapture } from '../shared/sent-text-capture.js'
306
307
  import {
307
308
  floodStatePath,
308
309
  floodWindowsPath,
@@ -5608,6 +5609,8 @@ const rawRobustApiCall = createRetryApiCall({
5608
5609
  // system-message-observer.ts for the send-vs-edit and throttling contract.
5609
5610
  // Gated on the SAME condition as `initHistory` above — a non-main gateway
5610
5611
  // process never opens the DB, so an observer there would be pure noise.
5612
+ // The empty-body alarm (#4576) is the observer's own default — see
5613
+ // `defaultEmptyCardTextWarning` in system-message-observer.ts.
5611
5614
  const observeSentMessage = isGatewayMain && HISTORY_ENABLED
5612
5615
  ? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText })
5613
5616
  : undefined
@@ -22877,6 +22880,11 @@ async function initGatewayBot(): Promise<void> {
22877
22880
 
22878
22881
  bot = new Bot(TOKEN)
22879
22882
  installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
22883
+ // #4576 follow-up: FALLBACK card body. The observer takes the stored body off
22884
+ // the RESPONSE (`rich_message` → `text`/`caption`); this stamps the REQUEST body
22885
+ // on the resolved Message for the shapes a response can't supply. After the fmt
22886
+ // guard so it composes OUTSIDE it. See sent-text-capture.ts.
22887
+ installSentTextCapture(bot)
22880
22888
  // #3620 flood fuse — installed LAST so it composes OUTERMOST: the one seam no
22881
22889
  // outbound call can bypass (grammY has no route to the network that skips the
22882
22890
  // transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
@@ -29,12 +29,34 @@
29
29
  * card send in the gateway is routed through it, enforced by the
30
30
  * `check-bot-api-wrapping` lint guard.
31
31
  *
32
- * The observer reads the Telegram RESPONSE, not the request, which buys three
33
- * things for free:
32
+ * The observer reads the Telegram RESPONSE, which buys three things for free:
34
33
  * - the real `message_id` (the only thing a reply can point at),
35
34
  * - the chat and forum-topic the message actually landed in,
36
- * - the RENDERED text, which for a card is otherwise unrecoverable (it is
37
- * composed from live tool activity and never persisted anywhere).
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.
38
60
  *
39
61
  * Send vs edit is NOT guessed from the verb (verb tagging is not uniform
40
62
  * across call sites and would rot). It falls out of the data: an edit returns
@@ -55,6 +77,9 @@
55
77
  * is observing.
56
78
  */
57
79
 
80
+ import { readSentText } from '../shared/sent-text-capture.js'
81
+ import { extractRichMessageText } from './rich-message-handler.js'
82
+
58
83
  /** The subset of a Telegram `Message` response this observer reads. */
59
84
  export interface SentMessageLike {
60
85
  message_id?: unknown
@@ -62,6 +87,7 @@ export interface SentMessageLike {
62
87
  message_thread_id?: unknown
63
88
  text?: unknown
64
89
  caption?: unknown
90
+ rich_message?: unknown
65
91
  }
66
92
 
67
93
  /** The subset of `robustApiCall`'s opts the observer reads. */
@@ -91,6 +117,23 @@ export interface SystemMessageObserverDeps {
91
117
  updateText: (args: { chat_id: string; message_id: number; text: string }) => boolean
92
118
  /** Injectable clock for the edit throttle. Defaults to `Date.now`. */
93
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
94
137
  }
95
138
 
96
139
  export interface SystemMessageObserverOptions {
@@ -133,7 +176,20 @@ export function normalizeSendVerb(verb: string | undefined | null): string | nul
133
176
  *
134
177
  * The response's own `chat.id` wins over the caller's `chat_id` opt: it is what
135
178
  * Telegram actually delivered to, and several call sites pass no `chat_id` at
136
- * all. Pure.
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.
137
193
  */
138
194
  export function extractSentMessage(
139
195
  result: unknown,
@@ -157,13 +213,83 @@ export function extractSentMessage(
157
213
  ? opts.threadId
158
214
  : null
159
215
  const text =
160
- typeof msg.text === 'string' ? msg.text : typeof msg.caption === 'string' ? msg.caption : ''
216
+ (msg.rich_message != null ? extractRichMessageText(msg.rich_message) : undefined) ??
217
+ nonEmptyString(msg.text) ??
218
+ nonEmptyString(msg.caption) ??
219
+ readSentText(result) ??
220
+ ''
161
221
  return { chatId, messageId: id, threadId, text }
162
222
  }
163
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
+
164
288
  /** Per-id bookkeeping. `foreign` = the id belongs to a non-system row (a real
165
- * reply or an inbound); never write to it again. */
166
- type TrackedState = { lane: 'system' | 'foreign'; lastStoredMs: number }
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 }
167
293
 
168
294
  /**
169
295
  * Build the observer. The returned function is called with the RESOLVED result
@@ -177,6 +303,30 @@ export function makeSystemMessageObserver(
177
303
  const editRefreshMs = options?.editRefreshMs ?? DEFAULT_EDIT_REFRESH_MS
178
304
  const maxTracked = Math.max(1, options?.maxTracked ?? DEFAULT_MAX_TRACKED)
179
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
+ }
180
330
 
181
331
  function remember(key: string, state: TrackedState): void {
182
332
  tracked.set(key, state)
@@ -200,11 +350,30 @@ export function makeSystemMessageObserver(
200
350
  const t = now()
201
351
  const seen = tracked.get(key)
202
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
+
203
358
  if (seen != null) {
204
359
  if (seen.lane === 'foreign') return
205
- if (t - seen.lastStoredMs < editRefreshMs) 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
206
374
  if (deps.updateText({ chat_id: sent.chatId, message_id: sent.messageId, text: sent.text })) {
207
375
  seen.lastStoredMs = t
376
+ seen.storedLen = sent.text.length
208
377
  } else {
209
378
  // The row is gone (retention prune / delete) or was promoted to a
210
379
  // real `assistant` reply by recordOutbound. Either way this id is no
@@ -218,23 +387,30 @@ export function makeSystemMessageObserver(
218
387
  chat_id: sent.chatId,
219
388
  thread_id: sent.threadId,
220
389
  message_id: sent.messageId,
221
- kind: normalizeSendVerb(opts?.verb),
390
+ kind,
222
391
  text: sent.text,
223
392
  })
224
393
  if (inserted) {
225
- remember(key, { lane: 'system', lastStoredMs: t })
394
+ remember(key, { lane: 'system', lastStoredMs: t, storedLen: sent.text.length })
226
395
  return
227
396
  }
228
397
  // A row already exists for this id and we did not create it in this
229
398
  // process: either a real reply / inbound (leave it alone), or a card this
230
399
  // gateway posted before a restart. One probing update disambiguates —
231
- // `updateText` only ever matches a `system` row.
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
232
404
  const refreshed = deps.updateText({
233
405
  chat_id: sent.chatId,
234
406
  message_id: sent.messageId,
235
407
  text: sent.text,
236
408
  })
237
- remember(key, { lane: refreshed ? 'system' : 'foreign', lastStoredMs: t })
409
+ remember(key, {
410
+ lane: refreshed ? 'system' : 'foreign',
411
+ lastStoredMs: t,
412
+ storedLen: refreshed ? sent.text.length : 0,
413
+ })
238
414
  } catch {
239
415
  /* observing a send must never break the send */
240
416
  }