switchroom 0.20.0 → 0.20.2

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 (29) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +2 -2
  3. package/dist/auth-broker/index.js +4 -3
  4. package/dist/buzz-gateway/index.js +166 -6
  5. package/dist/cli/notion-write-pretool.mjs +2 -2
  6. package/dist/cli/switchroom.js +24704 -16399
  7. package/dist/host-control/main.js +44 -10
  8. package/dist/vault/approvals/kernel-server.js +4 -3
  9. package/dist/vault/broker/server.js +4 -3
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +79 -10
  12. package/telegram-plugin/dist/gateway/gateway.js +1400 -964
  13. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  14. package/telegram-plugin/gateway/access-store.ts +194 -0
  15. package/telegram-plugin/gateway/boot-briefing-builder.ts +135 -7
  16. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  17. package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
  18. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  19. package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
  20. package/telegram-plugin/gateway/gateway.ts +43 -123
  21. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  22. package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
  23. package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
  24. package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
  25. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  26. package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
  27. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  28. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  29. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { randomUUID } from "crypto";
27
+ import { join } from "path";
27
28
  import type { OutboundToBuzzMessage } from "./ipc-protocol.js";
28
29
  import {
29
30
  resolveRoute,
@@ -32,6 +33,10 @@ import {
32
33
  type BuzzCoords,
33
34
  type Channel,
34
35
  } from "./channel-route.js";
36
+ import {
37
+ createCorrelationStore,
38
+ type CorrelationStore,
39
+ } from "./buzz-mirror-correlation-store.js";
35
40
 
36
41
  export type BuzzPeerSender = (msg: OutboundToBuzzMessage) => boolean;
37
42
 
@@ -47,6 +52,14 @@ export interface BuzzMirrorConfig {
47
52
  * threaded replies still work (they carry their own channelId).
48
53
  */
49
54
  defaultChannelId: string;
55
+ /**
56
+ * Absolute path to the durable msg→Buzz correlation journal (#4222). When
57
+ * set, the `${chatId}:${messageId}` → published-event map is persisted and
58
+ * reloaded on construction, so an `edit_message` correction survives a gateway
59
+ * restart. Omit (dev/one-shot/tests) to keep the map in-memory only — same
60
+ * bound, no durability. See `buzz-mirror-correlation-store.ts`.
61
+ */
62
+ correlationJournalPath?: string;
50
63
  /** Optional log sink (defaults to a no-op). */
51
64
  log?: (msg: string) => void;
52
65
  }
@@ -68,6 +81,27 @@ export interface MirrorReplyInput {
68
81
  * the published Buzz event to correct.
69
82
  */
70
83
  telegramMessageKeys: string[];
84
+ /**
85
+ * `${chatId}:${messageId}` of the Telegram message this outbound answer is
86
+ * itself a reply to (its Telegram `reply_to` antecedent), when it has one.
87
+ * Used ONLY on the telegram-origin path to thread the mirrored Buzz event
88
+ * under the antecedent's previously-published Buzz event (NIP-10 outbound
89
+ * continuity). When the antecedent was never mirrored (e.g. it is a user's
90
+ * inbound message, or it aged past the correlation bound) the lookup misses
91
+ * and the mirror stays a flat top-level post — no wrong/guessed tag. Absent
92
+ * for a non-reply answer. Ignored on the buzz-origin path (that thread is
93
+ * bound by `ownerBuzzCoords`, not a Telegram antecedent).
94
+ */
95
+ antecedentTelegramMessageKey?: string;
96
+ /**
97
+ * True IFF `antecedentTelegramMessageKey` is the quote-opt-in DEFAULT — i.e.
98
+ * the caller had no explicit/model-supplied `reply_to` and defaulted it to the
99
+ * latest INBOUND user message (#4301). That message is never in the
100
+ * correlation store, so its lookup ALWAYS misses; distinguishing it lets the
101
+ * mirror log the expected flat fallback quietly with a distinct reason instead
102
+ * of as an "outbound thread MISS", so a genuine eviction miss stays visible.
103
+ */
104
+ antecedentIsQuoteOptInDefault?: boolean;
71
105
  }
72
106
 
73
107
  export interface MirrorCorrectionInput {
@@ -86,6 +120,14 @@ const MAX_TRACKED = 4096;
86
120
  interface PendingPublish {
87
121
  channelId: string;
88
122
  telegramMessageKeys: string[];
123
+ /**
124
+ * The NIP-10 thread root this in-flight event belongs to, when it threaded
125
+ * under a parent. Undefined for a fresh top-level post — in which case the
126
+ * event's OWN id (learned in `onPublishResult`) becomes the thread root. Stored
127
+ * so the correlation record carries the root a LATER reply's `root` marker
128
+ * needs.
129
+ */
130
+ threadRootId?: string;
89
131
  }
90
132
 
91
133
  class BuzzMirror {
@@ -97,9 +139,20 @@ class BuzzMirror {
97
139
  private readonly pending = new Map<string, PendingPublish>();
98
140
  private readonly pendingOrder: string[] = [];
99
141
 
100
- /** `${chatId}:${messageId}` → the published Buzz event it maps to. */
101
- private readonly msgToBuzz = new Map<string, { eventId: string; channelId: string }>();
102
- private readonly msgOrder: string[] = [];
142
+ /**
143
+ * `${chatId}:${messageId}` the published Buzz event it maps to. Durable
144
+ * (JSONL journal) when `correlationJournalPath` is configured, so a correction
145
+ * survives a gateway restart (#4222); the store enforces the same MAX_TRACKED
146
+ * FIFO bound in memory and on disk.
147
+ */
148
+ private readonly msgToBuzz: CorrelationStore;
149
+
150
+ /**
151
+ * Count of `mirrorCorrection` calls whose key was genuinely absent from the
152
+ * correlation store (never mirrored, or evicted past the bound) — surfaced for
153
+ * the loud-miss regression assertion, not just the log line.
154
+ */
155
+ private correctionMisses = 0;
103
156
 
104
157
  /** `${chatId}:${messageId}` → live correction debounce timer. */
105
158
  private readonly correctionTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -107,6 +160,16 @@ class BuzzMirror {
107
160
  constructor(cfg: BuzzMirrorConfig) {
108
161
  this.cfg = cfg;
109
162
  this.log = cfg.log ?? (() => {});
163
+ this.msgToBuzz = createCorrelationStore({
164
+ journalPath: cfg.correlationJournalPath,
165
+ capacity: MAX_TRACKED,
166
+ log: this.log,
167
+ });
168
+ }
169
+
170
+ /** Test/introspection: number of corrections that missed the correlation store. */
171
+ getCorrectionMisses(): number {
172
+ return this.correctionMisses;
110
173
  }
111
174
 
112
175
  /** Register the transport to the duplex Buzz peer (ipcServer.sendToBuzzPeer). */
@@ -114,6 +177,13 @@ class BuzzMirror {
114
177
  this.sender = sender;
115
178
  }
116
179
 
180
+ /** Release the correlation journal fd and cancel any pending correction timers. */
181
+ close(): void {
182
+ for (const timer of this.correctionTimers.values()) clearTimeout(timer);
183
+ this.correctionTimers.clear();
184
+ this.msgToBuzz.close();
185
+ }
186
+
117
187
  private evict<T>(map: Map<string, T>, order: string[]): void {
118
188
  while (order.length > MAX_TRACKED) {
119
189
  const k = order.shift();
@@ -158,10 +228,61 @@ class BuzzMirror {
158
228
  replyToEventId = input.ownerBuzzCoords.eventId;
159
229
  threadRootId = input.ownerBuzzCoords.threadRoot;
160
230
  } else {
161
- // TELEGRAM-origin → fresh top-level post to the configured channel. Not
162
- // an owner-bound thread, so the S1 guard does not apply (design §3.3).
231
+ // TELEGRAM-origin → post to the configured channel. Not an owner-bound
232
+ // thread, so the S1 guard does not apply (design §3.3).
163
233
  if (!this.cfg.defaultChannelId) return; // no channel to post into
164
234
  channelId = this.cfg.defaultChannelId;
235
+
236
+ // NIP-10 OUTBOUND thread continuity: if this answer is itself a reply to
237
+ // a Telegram message that was ALREADY mirrored to Buzz, thread the new
238
+ // event under that antecedent's published event — a `reply` marker for
239
+ // the immediate parent and a `root` marker for the thread root — so a
240
+ // Telegram-origin reply chain renders threaded on the Buzz desktop
241
+ // instead of flat. The antecedent→event resolution reuses the SAME
242
+ // durable msg→event correlation store (#4280) the correction path uses;
243
+ // no new lookup surface. A MISS (antecedent never mirrored — e.g. it is
244
+ // the user's own inbound message — or evicted past MAX_TRACKED) leaves
245
+ // the post flat rather than emitting a wrong/guessed tag.
246
+ if (input.antecedentTelegramMessageKey) {
247
+ const parent = this.msgToBuzz.get(input.antecedentTelegramMessageKey);
248
+ if (parent && parent.channelId === channelId) {
249
+ replyToEventId = parent.eventId; // immediate parent → NIP-10 `reply`
250
+ // Thread root → NIP-10 `root`. Fall back to the parent's own id when
251
+ // the parent has no recorded root (it was itself top-level, or was
252
+ // journaled before threadRoot was tracked): then parent IS the root.
253
+ threadRootId = parent.threadRoot ?? parent.eventId;
254
+ } else if (parent) {
255
+ // #4299 CROSS-CHANNEL GUARD: the antecedent WAS mirrored, but into a
256
+ // DIFFERENT Buzz channel than this event's target (e.g. it was
257
+ // recorded via a buzz-origin threaded reply whose channelId came from
258
+ // the inbound event's own `h`-tag). This event publishes into
259
+ // `defaultChannelId`; threading it under a foreign-channel parent
260
+ // would carry e-tags that point into another group. Mirror FLAT
261
+ // instead — same fallback as a MISS, no cross-group e-tag.
262
+ this.log(
263
+ `buzz-mirror: outbound thread CROSS-CHANNEL — antecedent ` +
264
+ `${input.antecedentTelegramMessageKey} was mirrored into channel ` +
265
+ `${parent.channelId} != target ${channelId}; mirroring flat ` +
266
+ `(no cross-group e-tag)`,
267
+ );
268
+ } else if (input.antecedentIsQuoteOptInDefault) {
269
+ // #4301: quote-opt-in defaulted `reply_to` to the latest INBOUND user
270
+ // message, which is never in the correlation store — so this "miss"
271
+ // is EXPECTED, not an eviction. Log it quietly with a distinct reason
272
+ // (no "MISS") so genuine eviction misses stay visible in the logs.
273
+ this.log(
274
+ `buzz-mirror: outbound thread default-quote — antecedent ` +
275
+ `${input.antecedentTelegramMessageKey} is the latest inbound user ` +
276
+ `message (never mirrored); mirroring flat (expected, not an eviction)`,
277
+ );
278
+ } else {
279
+ this.log(
280
+ `buzz-mirror: outbound thread MISS — no Buzz correlation for ` +
281
+ `Telegram antecedent ${input.antecedentTelegramMessageKey}; ` +
282
+ `mirroring flat (never mirrored, or evicted past the bound)`,
283
+ );
284
+ }
285
+ }
165
286
  }
166
287
 
167
288
  this.publish(
@@ -187,7 +308,22 @@ class BuzzMirror {
187
308
  mirrorCorrection(input: MirrorCorrectionInput): void {
188
309
  try {
189
310
  const target = this.msgToBuzz.get(input.telegramMessageKey);
190
- if (!target) return; // this Telegram message was never mirrored to Buzz
311
+ if (!target) {
312
+ // No mapping for this key — either it was genuinely never mirrored, or
313
+ // it aged out past MAX_TRACKED (memory AND journal). Either way the
314
+ // correction cannot land, so DON'T fail silently: emit a loud log and
315
+ // bump the miss counter so the gap is observable (#4222, audit "at
316
+ // minimum" ask). Pre-#4222 a restart also landed here (empty map) — the
317
+ // durable journal is what keeps that from being the common case.
318
+ this.correctionMisses++;
319
+ this.log(
320
+ `buzz-mirror: CORRECTION MISS — no Buzz correlation for Telegram ` +
321
+ `message ${input.telegramMessageKey}; the edit could NOT be mirrored ` +
322
+ `(never mirrored, or evicted past MAX_TRACKED=${MAX_TRACKED}). ` +
323
+ `Buzz copy may be stale. total_misses=${this.correctionMisses}`,
324
+ );
325
+ return;
326
+ }
191
327
 
192
328
  const existing = this.correctionTimers.get(input.telegramMessageKey);
193
329
  if (existing) clearTimeout(existing);
@@ -239,12 +375,17 @@ class BuzzMirror {
239
375
  return;
240
376
  }
241
377
  // Record the published event against each Telegram message it mirrored, so
242
- // a later edit_message on any of them can target it for a correction.
378
+ // a later edit_message on any of them can target it for a correction AND a
379
+ // later reply whose antecedent is one of these messages can thread under it.
380
+ // The store persists (durable journal) + enforces the MAX_TRACKED FIFO bound.
381
+ //
382
+ // threadRoot: the root this event belongs to. If it threaded under a parent
383
+ // (`p.threadRootId` set) that parent's root IS this event's root; otherwise
384
+ // this is a fresh top-level post and its OWN id is the thread root.
385
+ const threadRoot = p.threadRootId ?? msg.eventId;
243
386
  for (const key of p.telegramMessageKeys) {
244
- this.msgToBuzz.set(key, { eventId: msg.eventId, channelId: p.channelId });
245
- this.msgOrder.push(key);
387
+ this.msgToBuzz.set(key, { eventId: msg.eventId, channelId: p.channelId, threadRoot });
246
388
  }
247
- this.evict(this.msgToBuzz, this.msgOrder);
248
389
  }
249
390
 
250
391
  private publish(
@@ -270,6 +411,7 @@ class BuzzMirror {
270
411
  this.pending.set(correlationId, {
271
412
  channelId: fields.channelId,
272
413
  telegramMessageKeys,
414
+ threadRootId: fields.threadRootId,
273
415
  });
274
416
  this.pendingOrder.push(correlationId);
275
417
  this.evict(this.pending, this.pendingOrder);
@@ -285,6 +427,7 @@ let singleton: BuzzMirror | null = null;
285
427
  * every hook site is a no-op.
286
428
  */
287
429
  export function initBuzzMirror(cfg: BuzzMirrorConfig): BuzzMirror {
430
+ singleton?.close(); // release any prior instance's journal fd + timers
288
431
  singleton = new BuzzMirror(cfg);
289
432
  return singleton;
290
433
  }
@@ -293,6 +436,24 @@ export function getBuzzMirror(): BuzzMirror | null {
293
436
  return singleton;
294
437
  }
295
438
 
439
+ /**
440
+ * Resolve the durable msg→Buzz correlation journal path from env (#4222). Lives
441
+ * under the same `$TELEGRAM_STATE_DIR/buzz/` dir as the sidecar's dedup journal
442
+ * but at a DISTINCT filename — a collision on `journal.jsonl` would corrupt
443
+ * both. `BUZZ_MIRROR_CORRELATION_PATH` overrides. When `TELEGRAM_STATE_DIR` is
444
+ * unset (dev/one-shot), returns undefined so the store degrades to in-memory
445
+ * only rather than scattering a journal under the home dir.
446
+ */
447
+ export function resolveCorrelationJournalPath(
448
+ env: Record<string, string | undefined> = process.env,
449
+ ): string | undefined {
450
+ const override = env.BUZZ_MIRROR_CORRELATION_PATH?.trim();
451
+ if (override) return override;
452
+ const stateDir = env.TELEGRAM_STATE_DIR?.trim();
453
+ if (!stateDir) return undefined;
454
+ return join(stateDir, "buzz", "mirror-correlation.jsonl");
455
+ }
456
+
296
457
  /**
297
458
  * Boot the hub mirror from env at gateway startup — the single wiring seam the
298
459
  * gateway calls. DARK BY DEFAULT and by construction: returns null (leaving
@@ -300,8 +461,10 @@ export function getBuzzMirror(): BuzzMirror | null {
300
461
  * (1) `BUZZ_ENABLED` is truthy, AND
301
462
  * (2) the S2-narrowed mode (`parseConfiguredMirrorMode`) is `both`;
302
463
  * a configured `origin`/`off` degrades to dark, never a half-live mirror.
303
- * The Buzz env vars are UNSET everywhere in this branch (projection deferred),
304
- * so in practice this is inert. `sender` is the transport to the duplex peer
464
+ * The Buzz env vars are projected at compose time from `channels.buzz`
465
+ * (src/agents/compose.ts), with BUZZ_ENABLED=1 gated on `enabled === true`;
466
+ * an enabled:false/absent block leaves them unset, so for those agents this
467
+ * is inert by construction. `sender` is the transport to the duplex peer
305
468
  * (`ipcServer.sendToBuzzPeer`). Returns the booted instance for tests.
306
469
  */
307
470
  export function maybeBootBuzzMirror(
@@ -315,6 +478,7 @@ export function maybeBootBuzzMirror(
315
478
  mode,
316
479
  agentName: env.SWITCHROOM_AGENT_NAME?.trim() ?? "",
317
480
  defaultChannelId: env.BUZZ_CHANNEL_IDS?.trim() ?? "",
481
+ correlationJournalPath: resolveCorrelationJournalPath(env),
318
482
  log: (m) => process.stderr.write(`telegram gateway: buzz-mirror — ${m}\n`),
319
483
  });
320
484
  bm.attachSender(sender);
@@ -323,6 +487,7 @@ export function maybeBootBuzzMirror(
323
487
 
324
488
  /** Test-only: tear down the singleton so cases don't leak state into each other. */
325
489
  export function __resetBuzzMirrorForTests(): void {
490
+ singleton?.close();
326
491
  singleton = null;
327
492
  }
328
493
 
@@ -127,6 +127,7 @@ import {
127
127
  routeInbound,
128
128
  admitInbound,
129
129
  buildReplyForwardContext,
130
+ resolveReplyToFromBuffer,
130
131
  buildInboundEnvelope,
131
132
  INBOUND_ROUTER_V2,
132
133
  runPreTurnIntercepts,
@@ -433,7 +434,7 @@ import { parseLitellmNoticeWindowMs } from '../litellm-local-notice.js'
433
434
  import { createLitellmLocalNoticeRunner, decideRateLimitedSurface } from './litellm-local-notice-wiring.js'
434
435
  import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
435
436
  import { startRestartWatchdog } from './restart-watchdog.js'
436
- import { validateStringArray } from './access-validator.js'
437
+ import { createAccessStore } from './access-store.js'
437
438
 
438
439
  /**
439
440
  * Truncation cap for the `reply_to_text` channel-meta attribute (issue #119).
@@ -1536,10 +1537,6 @@ export type Access = {
1536
1537
  }
1537
1538
  }
1538
1539
 
1539
- function defaultAccess(): Access {
1540
- return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} }
1541
- }
1542
-
1543
1540
  // Rich-message wire cap (#2669): the rich path allows 32768 UTF-8 chars.
1544
1541
  const MAX_CHUNK_LIMIT = RICH_MESSAGE_MAX_CHARS
1545
1542
  const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
@@ -1580,90 +1577,18 @@ function assertSendable(f: string): void {
1580
1577
  }
1581
1578
  }
1582
1579
 
1583
- function readAccessFile(): Access {
1584
- try {
1585
- const raw = readFileSync(ACCESS_FILE, 'utf8')
1586
- const parsed = JSON.parse(raw) as Partial<Access>
1587
- const allowFrom = validateStringArray('allowFrom', parsed.allowFrom ?? [])
1588
- const groups: Record<string, GroupPolicy> = {}
1589
- for (const [chatId, policy] of Object.entries(parsed.groups ?? {})) {
1590
- groups[chatId] = {
1591
- ...policy,
1592
- allowFrom: validateStringArray(`groups.${chatId}.allowFrom`, policy.allowFrom ?? []),
1593
- }
1594
- }
1595
- return {
1596
- dmPolicy: parsed.dmPolicy ?? 'pairing',
1597
- allowFrom,
1598
- groups,
1599
- pending: parsed.pending ?? {},
1600
- mentionPatterns: parsed.mentionPatterns,
1601
- ackReaction: parsed.ackReaction,
1602
- replyToMode: parsed.replyToMode,
1603
- textChunkLimit: parsed.textChunkLimit,
1604
- chunkMode: parsed.chunkMode,
1605
- parseMode: parsed.parseMode,
1606
- disableLinkPreview: parsed.disableLinkPreview,
1607
- coalescingGapMs: parsed.coalescingGapMs,
1608
- litellmNoticeWindowMs: parsed.litellmNoticeWindowMs,
1609
- coalesceMaxAttachments: parsed.coalesceMaxAttachments,
1610
- interruptSafeBoundary: parsed.interruptSafeBoundary,
1611
- interruptMaxWaitMs: parsed.interruptMaxWaitMs,
1612
- statusReactions: parsed.statusReactions,
1613
- historyEnabled: parsed.historyEnabled,
1614
- historyRetentionDays: parsed.historyRetentionDays,
1615
- // #596: telegram features projected into access.json by scaffold.
1616
- // Without these passthroughs, gateway readers (`access.voice_in`,
1617
- // `access.telegraph`, `access.stickers`) silently see undefined.
1618
- stickers: parsed.stickers,
1619
- voice_in: parsed.voice_in,
1620
- voice_out: parsed.voice_out,
1621
- telegraph: parsed.telegraph,
1622
- // #789: button-choice-confirmation config projected by scaffold.
1623
- button_choice_confirmation: parsed.button_choice_confirmation,
1624
- }
1625
- } catch (err) {
1626
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
1627
- try { renameSync(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`) } catch {}
1628
- process.stderr.write(`telegram gateway: access.json is corrupt, moved aside. Starting fresh.\n`)
1629
- return defaultAccess()
1630
- }
1631
- }
1632
-
1633
- const BOOT_ACCESS: Access | null = STATIC
1634
- ? (() => {
1635
- const a = readAccessFile()
1636
- if (a.dmPolicy === 'pairing') {
1637
- process.stderr.write('telegram gateway: static mode — dmPolicy "pairing" downgraded to "allowlist"\n')
1638
- a.dmPolicy = 'allowlist'
1639
- }
1640
- a.pending = {}
1641
- return a
1642
- })()
1643
- : null
1644
-
1645
- function loadAccess(): Access {
1646
- return BOOT_ACCESS ?? readAccessFile()
1647
- }
1648
-
1649
- /**
1650
- * Read `people.json` (the scaffold's plain projection of `users:` entries
1651
- * that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
1652
- * returns an empty array rather than throwing — this feature must never
1653
- * block startup. Unlike `access.json` this file is never gateway-mutated,
1654
- * so there's no "move corrupt file aside" concern; the scaffold owns and
1655
- * regenerates it on every reconcile.
1656
- */
1657
- function readPeopleFile(): RawPersonEntry[] {
1658
- try {
1659
- const raw = readFileSync(PEOPLE_FILE, 'utf8')
1660
- const parsed = JSON.parse(raw) as { entries?: unknown }
1661
- if (!Array.isArray(parsed.entries)) return []
1662
- return parsed.entries as RawPersonEntry[]
1663
- } catch {
1664
- return []
1665
- }
1666
- }
1580
+ // Access/allowlist file layer (access.json + people.json). Extracted to
1581
+ // ./access-store.ts (switchroom#4248) to relieve the gateway line-ratchet;
1582
+ // behavior is byte-identical. The factory runs the static-mode BOOT_ACCESS
1583
+ // snapshot eagerly at THIS point in startup — same timing as the inline
1584
+ // version so the frozen-allowlist semantics are unchanged.
1585
+ const { loadAccess, readPeopleFile, assertAllowedChat, saveAccess, pruneExpired } =
1586
+ createAccessStore({
1587
+ accessFile: ACCESS_FILE,
1588
+ peopleFile: PEOPLE_FILE,
1589
+ stateDir: STATE_DIR,
1590
+ isStatic: STATIC,
1591
+ })
1667
1592
 
1668
1593
  /**
1669
1594
  * Boot-time-only person-name directory (see resolve-person.ts module doc).
@@ -1673,34 +1598,6 @@ function readPeopleFile(): RawPersonEntry[] {
1673
1598
  */
1674
1599
  let PERSON_DIRECTORY: PersonDirectory = { byTelegramKey: {} }
1675
1600
 
1676
- function assertAllowedChat(chat_id: string | number): void {
1677
- const id = String(chat_id)
1678
- const access = loadAccess()
1679
- if (access.allowFrom.includes(id)) return
1680
- if (id in access.groups) return
1681
- throw new Error(`chat ${id} is not allowlisted — add via /telegram:access`)
1682
- }
1683
-
1684
- function saveAccess(a: Access): void {
1685
- if (STATIC) return
1686
- mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
1687
- const tmp = ACCESS_FILE + '.tmp'
1688
- writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 })
1689
- renameSync(tmp, ACCESS_FILE)
1690
- }
1691
-
1692
- function pruneExpired(a: Access): boolean {
1693
- const now = Date.now()
1694
- let changed = false
1695
- for (const [code, p] of Object.entries(a.pending)) {
1696
- if (p.expiresAt < now) {
1697
- delete a.pending[code]
1698
- changed = true
1699
- }
1700
- }
1701
- return changed
1702
- }
1703
-
1704
1601
  // ─── History ──────────────────────────────────────────────────────────────
1705
1602
  const HISTORY_ACCESS = loadAccess()
1706
1603
  const HISTORY_ENABLED = HISTORY_ACCESS.historyEnabled !== false
@@ -10181,7 +10078,7 @@ if (isGatewayMain && !STATIC && OBLIGATION_LEDGER_ENABLED) {
10181
10078
  // --continue suppression, resume-window dedup, budget) lives in
10182
10079
  // boot-briefing-wiring.ts / boot-briefing-builder.ts; never throws.
10183
10080
  if (isGatewayMain && HISTORY_ENABLED) {
10184
- maybeQueueBootBriefing({
10081
+ await maybeQueueBootBriefing({
10185
10082
  env: process.env,
10186
10083
  stateDir: STATE_DIR,
10187
10084
  resumeMsg: bootResumeInbound?.msg ?? null,
@@ -15182,17 +15079,39 @@ export async function handleInbound(
15182
15079
 
15183
15080
  // Reply-to + forward-origin context — moved to buildReplyForwardContext
15184
15081
  // (#2996 P7 PR-10, pure builder).
15082
+ const replyForwardCtx = buildReplyForwardContext({
15083
+ ctx,
15084
+ coalescedForwardOrigins,
15085
+ replyToTextMax: REPLY_TO_TEXT_MAX,
15086
+ })
15185
15087
  const {
15186
15088
  replyToMessageId,
15187
- replyToText,
15188
- replyToTextEscaped,
15189
15089
  forwardOrigins,
15190
15090
  forwardOriginMeta,
15191
15091
  primaryForwardOrigin,
15192
- } = buildReplyForwardContext({
15193
- ctx,
15194
- coalescedForwardOrigins,
15092
+ } = replyForwardCtx
15093
+
15094
+ // Reply-to buffer fallback (post-reset continuity, resolveReplyToFromBuffer).
15095
+ // On a native reply to the BOT's OWN message, Telegram delivers
15096
+ // reply_to_message.message_id but NOT its .text — so the live reply text is
15097
+ // empty even though we authored (and, via recordOutbound, persisted) that
15098
+ // message to history.db (role='assistant', 30-day retention). Recover it so
15099
+ // the antecedent survives a session reset. Fills replyToText (raw, so the
15100
+ // recordInbound write below persists it — envelope-only would leave the row
15101
+ // NULL and starve future briefings), replyToTextEscaped (channel meta), and
15102
+ // replyToRole (the reply_to_role attribute). Only when the live text is
15103
+ // empty; degrades silently when history is off / the row is missing.
15104
+ const {
15105
+ replyToText,
15106
+ replyToTextEscaped,
15107
+ replyToRole,
15108
+ } = resolveReplyToFromBuffer({
15109
+ replyToMessageId,
15110
+ replyToText: replyForwardCtx.replyToText,
15111
+ replyToTextEscaped: replyForwardCtx.replyToTextEscaped,
15112
+ historyEnabled: HISTORY_ENABLED,
15195
15113
  replyToTextMax: REPLY_TO_TEXT_MAX,
15114
+ lookup: (messageId) => lookupMessageRoleAndText(chat_id, messageId),
15196
15115
  })
15197
15116
 
15198
15117
  if (HISTORY_ENABLED) {
@@ -15269,6 +15188,7 @@ export async function handleInbound(
15269
15188
  priorAssistantPreview,
15270
15189
  replyToMessageId,
15271
15190
  replyToTextEscaped,
15191
+ replyToRole,
15272
15192
  forwardOriginMeta,
15273
15193
  topicFramingEnabled: TOPIC_FRAMING_ENABLED,
15274
15194
  personDirectory: PERSON_DIRECTORY,
@@ -151,9 +151,20 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
151
151
  // (for channel meta).
152
152
  const replyToMsg = p.ctx.message?.reply_to_message
153
153
  const replyToMessageId = replyToMsg?.message_id
154
- const replyToTextRaw = replyToMsg
155
- ? (replyToMsg.text ?? replyToMsg.caption ?? undefined)
156
- : undefined
154
+ // Native partial-quote preference (Bot API 7.0+ `message.quote`, issue #119
155
+ // follow-up). When the user long-presses a message and drag-selects a
156
+ // substring before choosing Reply, Telegram delivers only that quoted span
157
+ // on `message.quote.text` — the user is pointing at that exact slice, so it
158
+ // is a stronger antecedent than the full parent message. Prefer it over the
159
+ // parent `.text`/`.caption`, and over the history-buffer fallback the
160
+ // gateway runs when neither is present. Accessed defensively in case the
161
+ // installed grammy/@grammyjs/types predate `TextQuote`.
162
+ const quoteText = p.ctx.message?.quote?.text
163
+ const replyToTextRaw = (quoteText != null && quoteText.length > 0)
164
+ ? quoteText
165
+ : replyToMsg
166
+ ? (replyToMsg.text ?? replyToMsg.caption ?? undefined)
167
+ : undefined
157
168
  const replyToText = replyToTextRaw != null
158
169
  ? (replyToTextRaw.length > p.replyToTextMax
159
170
  ? replyToTextRaw.slice(0, p.replyToTextMax - 1) + '…'
@@ -183,6 +194,73 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
183
194
  }
184
195
  }
185
196
 
197
+ /** Inputs for {@link resolveReplyToFromBuffer}. */
198
+ export interface ReplyToBufferFallbackParams {
199
+ /** From {@link buildReplyForwardContext}. */
200
+ replyToMessageId: number | undefined
201
+ /** Raw reply text off the live update (for the SQLite write); empty when the
202
+ * reply target is the bot's own message (Telegram omits its text). */
203
+ replyToText: string | undefined
204
+ /** XML-escaped reply text for the channel meta; empty in the same case. */
205
+ replyToTextEscaped: string | undefined
206
+ /** HISTORY_ENABLED — the DB is only present when history is on. */
207
+ historyEnabled: boolean
208
+ /** REPLY_TO_TEXT_MAX. */
209
+ replyToTextMax: number
210
+ /** `lookupMessageRoleAndText` bound to the chat, or any equivalent. May
211
+ * throw (e.g. requireDb when history disabled mid-run) — this is caught. */
212
+ lookup: (messageId: number) => { role: 'user' | 'assistant'; text: string } | null
213
+ }
214
+
215
+ /**
216
+ * Reply-to buffer fallback (post-reset continuity). On a native reply to the
217
+ * BOT's OWN message, Telegram delivers `reply_to_message.message_id` but NOT
218
+ * its `.text` — so the live reply text is empty even though the gateway
219
+ * authored (and, via `recordOutbound`, persisted) that message. This recovers
220
+ * the antecedent from the local history buffer so it survives a session reset
221
+ * (`resume_mode: handoff`), where the transcript is gone.
222
+ *
223
+ * Returns updated `replyToText` (raw, for the SQLite `recordInbound` write —
224
+ * envelope-only would leave the row NULL and starve future handoff briefings),
225
+ * `replyToTextEscaped` (for the channel-meta `reply_to_text`), and the
226
+ * recovered `replyToRole` ('assistant' = the bot's own message, disambiguating
227
+ * the "you're replying to yourself" case). Pure except for the injected
228
+ * `lookup`. Only fills in when the live reply text is empty — never overwrites
229
+ * a non-empty live value (a partial-quote or a reply to a person's message).
230
+ * Degrades silently to the id-only inputs on any lookup failure.
231
+ */
232
+ export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
233
+ replyToText: string | undefined
234
+ replyToTextEscaped: string | undefined
235
+ replyToRole: 'user' | 'assistant' | undefined
236
+ } {
237
+ let replyToText = p.replyToText
238
+ let replyToTextEscaped = p.replyToTextEscaped
239
+ let replyToRole: 'user' | 'assistant' | undefined
240
+ const liveTextEmpty = replyToTextEscaped == null || replyToTextEscaped.length === 0
241
+ if (p.historyEnabled && p.replyToMessageId != null && liveTextEmpty) {
242
+ try {
243
+ const recovered = p.lookup(p.replyToMessageId)
244
+ if (recovered && recovered.text.length > 0) {
245
+ replyToText =
246
+ recovered.text.length > p.replyToTextMax
247
+ ? recovered.text.slice(0, p.replyToTextMax - 1) + '…'
248
+ : recovered.text
249
+ replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax)
250
+ replyToRole = recovered.role
251
+ } else if (recovered) {
252
+ // Row exists (authorship known) but text is empty/redacted — still
253
+ // surface the role so the model knows whose message it is replying to.
254
+ replyToRole = recovered.role
255
+ }
256
+ } catch {
257
+ // History disabled mid-run / requireDb throws / row missing — degrade
258
+ // silently to the id-only inputs (current behavior).
259
+ }
260
+ }
261
+ return { replyToText, replyToTextEscaped, replyToRole }
262
+ }
263
+
186
264
  /** Inputs for {@link buildInboundEnvelope} — every field is an at-call
187
265
  * captured value (steering meta, at-receipt snapshots, resolved attachments),
188
266
  * never a live getter. */
@@ -206,6 +284,11 @@ export interface EnvelopeBuildParams {
206
284
  priorAssistantPreview: string | undefined
207
285
  replyToMessageId: number | undefined
208
286
  replyToTextEscaped: string | undefined
287
+ /** Authorship of the replied-to message ('assistant' = the bot's own
288
+ * message, 'user' = a person's), when known — recovered from the history
289
+ * buffer by handleInbound's reply-to fallback. Undefined when the role
290
+ * can't be determined (e.g. text came live off the update, not the DB). */
291
+ replyToRole: 'user' | 'assistant' | undefined
209
292
  forwardOriginMeta: Record<string, string>
210
293
  /** TOPIC_FRAMING_ENABLED — fixed constant. */
211
294
  topicFramingEnabled: boolean
@@ -300,6 +383,13 @@ export function buildInboundEnvelope(p: EnvelopeBuildParams): InboundMessage {
300
383
  // Use the XML-escaped form for the meta — the raw form is in the
301
384
  // SQLite buffer for verbatim retrieval via get_recent_messages.
302
385
  ...(p.replyToTextEscaped != null && p.replyToTextEscaped.length > 0 ? { reply_to_text: p.replyToTextEscaped } : {}),
386
+ // Authorship of the replied-to message. Disambiguates "you are replying
387
+ // to the BOT's own message" (assistant) from "…to a person's message"
388
+ // (user) — the incident where a native reply to one of the bot's own
389
+ // messages ("is this added as a calendar invite yet?") left the agent
390
+ // guessing the antecedent. Free: the history-buffer lookup that recovers
391
+ // reply_to_text already returns the role. Emitted only when known.
392
+ ...(p.replyToRole != null ? { reply_to_role: p.replyToRole } : {}),
303
393
  // Forwarded-message origin (server-stamped, attrs-only — see above).
304
394
  // forwarded_from / forwarded_from_type / forwarded_from_id /
305
395
  // forwarded_date, plus numbered _2.. siblings for a multi-origin