switchroom 0.20.0 → 0.20.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.
Files changed (29) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +1 -1
  3. package/dist/auth-broker/index.js +1 -1
  4. package/dist/buzz-gateway/index.js +166 -6
  5. package/dist/cli/notion-write-pretool.mjs +1 -1
  6. package/dist/cli/switchroom.js +24701 -16397
  7. package/dist/host-control/main.js +41 -8
  8. package/dist/vault/approvals/kernel-server.js +1 -1
  9. package/dist/vault/broker/server.js +1 -1
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +79 -10
  12. package/telegram-plugin/dist/gateway/gateway.js +1397 -962
  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
@@ -47,7 +47,29 @@ HINDSIGHT_BANK="${HINDSIGHT_BANK_ID:-}"
47
47
  AGENT_DIR="${AGENT_DIR:-}"
48
48
  WORKSPACE_DIR="${WORKSPACE_DIR:-$AGENT_DIR}"
49
49
  MAX_MESSAGES="${HANDOFF_BRIEFING_MAX_MESSAGES:-20}"
50
- HINDSIGHT_TIMEOUT="${HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT:-4}"
50
+ # Hindsight is the only network hop; cap it at 3s so it finishes well inside
51
+ # start.sh's outer `timeout 10` kill budget (was 4s under a 5s outer, which
52
+ # left almost no margin — a slow recall could be killed mid-write). Local
53
+ # SQLite + daily-memory reads are sub-second, so 3s is the whole network cost.
54
+ HINDSIGHT_TIMEOUT="${HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT:-3}"
55
+
56
+ # Chat/thread scope for the recent-conversation section (#continuity). A
57
+ # forum/group agent's history.db holds messages from many topics; an unscoped
58
+ # "last 20" briefing pollutes the reorientation with unrelated threads. Prefer
59
+ # the surface that was mid-turn when the prior session ended (exported by
60
+ # start.sh from the pending-turn env on an interrupted boot); otherwise the
61
+ # python fallback derives the single most-recently-active (chat_id, thread_id)
62
+ # surface straight from the DB. Empty chat ⇒ let python derive it.
63
+ #
64
+ # TARGET_THREAD_ID is tri-state: a numbered thread scopes to that topic; the
65
+ # literal sentinel `NULL` means the surface's thread is genuinely NULL (a DM /
66
+ # forum General topic) and scopes with `thread_id IS NULL`; empty means the
67
+ # thread is UNKNOWN and falls back to chat-only scope (all threads). The
68
+ # pending-turn env writer emits `NULL` when it knows the interrupted turn's
69
+ # thread was null, so a General-topic interrupt reboots correctly scoped
70
+ # instead of pulling in every other topic's messages.
71
+ TARGET_CHAT_ID="${SWITCHROOM_PENDING_CHAT_ID:-}"
72
+ TARGET_THREAD_ID="${SWITCHROOM_PENDING_THREAD_ID:-}"
51
73
 
52
74
  # Determine output mode
53
75
  STDOUT_MODE=0
@@ -61,28 +83,119 @@ if [ -n "$TELEGRAM_STATE" ] && [ -d "$TELEGRAM_STATE" ]; then
61
83
  HISTORY_DB="$TELEGRAM_STATE/history.db"
62
84
  if [ -f "$HISTORY_DB" ] && command -v python3 >/dev/null 2>&1; then
63
85
  # Use python3's stdlib sqlite3 — no bun:sqlite, no extra deps.
64
- # Query the most recent $MAX_MESSAGES rows ordered by ts DESC, then
65
- # reverse for chronological display. We skip system messages (role NULL).
66
- TELEGRAM_ROWS=$(python3 - "$HISTORY_DB" "$MAX_MESSAGES" 2>/dev/null <<'PYEOF'
86
+ # Query the most recent $MAX_MESSAGES rows for the ACTIVE surface only
87
+ # (chat/thread-scoped, see above), ordered by ts DESC, then reverse for
88
+ # chronological display. We skip system messages (role NULL). The scope
89
+ # source is logged to stderr for boot diagnostics.
90
+ # python stderr is NOT suppressed: the script's only stderr output is the
91
+ # intentional scope breadcrumb + a single graceful line on any caught error
92
+ # (every DB path is wrapped in try/except → sys.exit(0)). start.sh runs this
93
+ # under `2>/dev/null`, so the breadcrumb is a debug/manual-run diagnostic.
94
+ TELEGRAM_ROWS=$(python3 - "$HISTORY_DB" "$MAX_MESSAGES" "$TARGET_CHAT_ID" "$TARGET_THREAD_ID" <<'PYEOF'
67
95
  import sys, sqlite3, datetime
68
96
 
69
97
  db_path = sys.argv[1]
70
98
  limit = int(sys.argv[2])
99
+ target_chat = sys.argv[3] if len(sys.argv) > 3 else ""
100
+ target_thread = sys.argv[4] if len(sys.argv) > 4 else ""
71
101
 
72
102
  try:
73
103
  conn = sqlite3.connect(db_path)
74
104
  conn.row_factory = sqlite3.Row
75
105
  cur = conn.cursor()
76
- # Fetch most recent rows; reverse for chronological output.
106
+
107
+ # Resolve the surface to scope to.
108
+ # - explicit env target (pending-turn chat/thread) wins — UNLESS that chat
109
+ # has zero rows (rotated/fresh DB), in which case we fall through to
110
+ # db-latest so a stale env target doesn't yield an empty section;
111
+ # - else derive the single most-recently-active (chat_id, thread_id).
112
+ # thread is tri-state: a real value, NULL (DM / general), or unknown.
113
+ # - target_thread == "NULL" (sentinel from start.sh / pending-turn-env)
114
+ # means the surface's thread is GENUINELY NULL (DM / general topic) —
115
+ # scope with `thread_id IS NULL`, do NOT pull in numbered-thread rows.
116
+ # - target_thread == "" (empty) means UNKNOWN — chat-only scope (all
117
+ # threads), the safe backward-compatible fallback.
118
+ scope_chat = None
119
+ scope_thread = None # int thread id
120
+ scope_thread_known = False # True once we know the exact thread (incl. NULL)
121
+ scope_thread_is_null = False # True when the surface's thread is NULL
122
+ scope_source = "unscoped"
123
+
124
+ def _chat_has_rows(chat_id):
125
+ cur.execute(
126
+ "SELECT 1 FROM messages WHERE role IN ('user', 'assistant') "
127
+ "AND chat_id = ? LIMIT 1",
128
+ [chat_id],
129
+ )
130
+ return cur.fetchone() is not None
131
+
132
+ def _derive_db_latest():
133
+ # The single most-recently-active (chat_id, thread_id) surface.
134
+ cur.execute(
135
+ """
136
+ SELECT chat_id, thread_id
137
+ FROM messages
138
+ WHERE role IN ('user', 'assistant')
139
+ ORDER BY ts DESC
140
+ LIMIT 1
141
+ """
142
+ )
143
+ return cur.fetchone()
144
+
145
+ # Env target — accept it only when its chat actually has rows (2b). A
146
+ # rotated/fresh DB can leave a pending-turn chat with no persisted
147
+ # messages; scoping to it would silently emit an empty section, so we
148
+ # fall through to db-latest instead.
149
+ if target_chat and _chat_has_rows(target_chat):
150
+ scope_chat = target_chat
151
+ scope_source = "env"
152
+ if target_thread == "NULL":
153
+ scope_thread_is_null = True
154
+ scope_thread_known = True
155
+ elif target_thread != "":
156
+ try:
157
+ scope_thread = int(target_thread)
158
+ scope_thread_known = True
159
+ except ValueError:
160
+ scope_thread = None # unparseable → chat-only scope
161
+ # else: unknown thread → chat-only scope (all threads of the chat)
162
+
163
+ if scope_chat is None:
164
+ latest = _derive_db_latest()
165
+ if latest is not None:
166
+ scope_chat = latest["chat_id"]
167
+ scope_source = "db-latest"
168
+ scope_thread_known = True
169
+ if latest["thread_id"] is None:
170
+ scope_thread_is_null = True
171
+ else:
172
+ scope_thread = latest["thread_id"]
173
+
174
+ if scope_chat is None:
175
+ # Empty DB (nothing to scope) — nothing to show.
176
+ sys.stderr.write("handoff-briefing: no messages to scope; empty section\n")
177
+ sys.exit(0)
178
+
179
+ where = ["role IN ('user', 'assistant')", "chat_id = ?"]
180
+ params = [scope_chat]
181
+ if scope_thread_known:
182
+ if scope_thread_is_null:
183
+ where.append("thread_id IS NULL")
184
+ else:
185
+ where.append("thread_id = ?")
186
+ params.append(scope_thread)
187
+ params.append(limit)
188
+
189
+ sys.stderr.write(
190
+ "handoff-briefing: scoping recent conversation to chat=%s thread=%s (source=%s)\n"
191
+ % (scope_chat, "NULL" if scope_thread_is_null else (scope_thread if scope_thread_known else "any"), scope_source)
192
+ )
193
+
77
194
  cur.execute(
78
- """
79
- SELECT role, user, ts, text
80
- FROM messages
81
- WHERE role IN ('user', 'assistant')
82
- ORDER BY ts DESC
83
- LIMIT ?
84
- """,
85
- (limit,),
195
+ "SELECT role, user, ts, text FROM messages WHERE "
196
+ + " AND ".join(where)
197
+ + " ORDER BY ts DESC LIMIT ?",
198
+ params,
86
199
  )
87
200
  rows = list(reversed(cur.fetchall()))
88
201
  conn.close()
@@ -110,6 +223,70 @@ $TELEGRAM_ROWS"
110
223
  fi
111
224
  fi
112
225
 
226
+ # ── Header (cheap, no network) — computed early so the recent-conversation
227
+ # section can be flushed to disk BEFORE the slow Hindsight hop. ──────────────
228
+ # Restart timestamp — model-facing: it lands in the resume-turn system prompt
229
+ # via --append-system-prompt ("You just restarted at …"). Render the agent's
230
+ # LOCAL am/pm wall clock, NOT UTC, so the restart turn never sees a competing
231
+ # UTC "now". Same SWITCHROOM_TIMEZONE → TZ → UTC cascade the daily section uses.
232
+ _TZ_VAL="${SWITCHROOM_TIMEZONE:-${TZ:-UTC}}"
233
+ TIMESTAMP=$(TZ="$_TZ_VAL" date '+%A %Y-%m-%d %I:%M %p %Z' 2>/dev/null || date '+%A %Y-%m-%d %I:%M %p %Z')
234
+ RESTART_REASON="unknown"
235
+ if [ -n "$AGENT_DIR" ] && [ -f "$AGENT_DIR/.restart-reason" ]; then
236
+ RESTART_REASON=$(cat "$AGENT_DIR/.restart-reason" 2>/dev/null | head -1 | tr -d '\r\n')
237
+ fi
238
+ if [ -n "${SWITCHROOM_PENDING_ENDED_VIA:-}" ]; then
239
+ RESTART_REASON="$SWITCHROOM_PENDING_ENDED_VIA"
240
+ fi
241
+ BRIEFING_HEADER="You just restarted at ${TIMESTAMP}. Previous session ended via: ${RESTART_REASON}. Consult this briefing before responding."
242
+
243
+ # Resolve the output destination up front (file mode only).
244
+ OUTPUT_FILE=""
245
+ OUTPUT_TMP=""
246
+ if [ "$STDOUT_MODE" != "1" ] && [ -n "$AGENT_DIR" ]; then
247
+ OUTPUT_FILE="$AGENT_DIR/.handoff-briefing.md"
248
+ OUTPUT_TMP="${OUTPUT_FILE}.tmp.$$"
249
+ fi
250
+
251
+ # Incremental emit (#continuity). Sections are flushed to the output file as
252
+ # each source resolves — the recent-conversation section (the highest-value,
253
+ # always-local part) is written BEFORE the network-bound Hindsight hop. So a
254
+ # late-stage kill (e.g. start.sh's outer `timeout`, SIGTERM by default) mid-Hindsight still
255
+ # leaves the recent conversation on disk instead of nothing. The first flush
256
+ # writes header+section atomically via tmp+mv; later sections append. In
257
+ # stdout/no-AGENT_DIR mode we buffer and print once at the end instead.
258
+ FILE_STARTED=0
259
+ STDOUT_BUFFER=""
260
+ emit_section() {
261
+ # $1 = section content (assumed non-empty)
262
+ section="$1"
263
+ if [ -n "$OUTPUT_FILE" ]; then
264
+ if [ "$FILE_STARTED" = "0" ]; then
265
+ # First section: header + divider + section, written atomically.
266
+ printf '%s\n\n---\n\n%s' "$BRIEFING_HEADER" "$section" > "$OUTPUT_TMP" \
267
+ && mv -f "$OUTPUT_TMP" "$OUTPUT_FILE"
268
+ FILE_STARTED=1
269
+ else
270
+ printf '\n\n---\n\n%s' "$section" >> "$OUTPUT_FILE"
271
+ fi
272
+ else
273
+ if [ -z "$STDOUT_BUFFER" ]; then
274
+ STDOUT_BUFFER="$section"
275
+ else
276
+ STDOUT_BUFFER="$STDOUT_BUFFER
277
+
278
+ ---
279
+
280
+ $section"
281
+ fi
282
+ fi
283
+ }
284
+
285
+ # Flush the recent-conversation section NOW, before Hindsight.
286
+ if [ -n "$TELEGRAM_SECTION" ]; then
287
+ emit_section "$TELEGRAM_SECTION"
288
+ fi
289
+
113
290
  # ── Source 2: Hindsight recall ──────────────────────────────────────────────────
114
291
  HINDSIGHT_SECTION=""
115
292
  if [ -n "$HINDSIGHT_URL" ] && [ -n "$HINDSIGHT_BANK" ] && command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then
@@ -133,14 +310,16 @@ $RECALL_TEXT"
133
310
  fi
134
311
  fi
135
312
  fi
313
+ if [ -n "$HINDSIGHT_SECTION" ]; then
314
+ emit_section "$HINDSIGHT_SECTION"
315
+ fi
136
316
 
137
317
  # ── Source 3: Today's daily memory ─────────────────────────────────────────────
138
- # Resolve "today" in the agent's LOCAL time — NOT the process default (UTC on
139
- # most hosts/CI). TODAY keys the daily-memory lookup (memory/${TODAY}.md); using
140
- # UTC here would look up the wrong day's file during the window where the local
141
- # date is ahead of/behind UTC, silently dropping today's memory. Same
142
- # SWITCHROOM_TIMEZONE TZ → UTC cascade the restart-timestamp render below uses.
143
- _TZ_VAL="${SWITCHROOM_TIMEZONE:-${TZ:-UTC}}"
318
+ # TODAY keys the daily-memory lookup (memory/${TODAY}.md) in the agent's LOCAL
319
+ # time (_TZ_VAL, resolved in the header block above) — NOT the process default
320
+ # (UTC on most hosts/CI). Using UTC here would look up the wrong day's file
321
+ # during the window where the local date is ahead of/behind UTC, silently
322
+ # dropping today's memory.
144
323
  DAILY_SECTION=""
145
324
  TODAY=$(TZ="$_TZ_VAL" date +%Y-%m-%d 2>/dev/null || date +%Y-%m-%d 2>/dev/null || true)
146
325
  if [ -n "$TODAY" ] && [ -n "$WORKSPACE_DIR" ]; then
@@ -154,66 +333,26 @@ $DAILY_CONTENT"
154
333
  fi
155
334
  fi
156
335
  fi
157
-
158
- # ── Assemble briefing ───────────────────────────────────────────────────────────
159
- # Restart timestamp — model-facing: it lands in the resume-turn system prompt
160
- # via --append-system-prompt ("You just restarted at …"). Render the agent's
161
- # LOCAL am/pm wall clock, NOT UTC, so the restart turn never sees a competing
162
- # UTC "now" (the whole point of the deterministic-local-time work). Same
163
- # SWITCHROOM_TIMEZONE → TZ → UTC cascade and `%A %Y-%m-%d %I:%M %p %Z` am/pm
164
- # format the UserPromptSubmit local-time hook (bin/timezone-hook.sh) uses.
165
- # (_TZ_VAL is computed once above, in the daily-memory section.)
166
- TIMESTAMP=$(TZ="$_TZ_VAL" date '+%A %Y-%m-%d %I:%M %p %Z' 2>/dev/null || date '+%A %Y-%m-%d %I:%M %p %Z')
167
-
168
- # Determine restart reason if available
169
- RESTART_REASON="unknown"
170
- if [ -n "$AGENT_DIR" ] && [ -f "$AGENT_DIR/.restart-reason" ]; then
171
- RESTART_REASON=$(cat "$AGENT_DIR/.restart-reason" 2>/dev/null | head -1 | tr -d '\r\n')
336
+ if [ -n "$DAILY_SECTION" ]; then
337
+ emit_section "$DAILY_SECTION"
172
338
  fi
173
- # Also check SWITCHROOM_PENDING_ENDED_VIA if set by start.sh
174
- if [ -n "${SWITCHROOM_PENDING_ENDED_VIA:-}" ]; then
175
- RESTART_REASON="$SWITCHROOM_PENDING_ENDED_VIA"
176
- fi
177
-
178
- # Build the briefing body
179
- SECTIONS=""
180
- for section in "$TELEGRAM_SECTION" "$HINDSIGHT_SECTION" "$DAILY_SECTION"; do
181
- if [ -n "$section" ]; then
182
- if [ -n "$SECTIONS" ]; then
183
- SECTIONS="$SECTIONS
184
-
185
- ---
186
339
 
187
- $section"
188
- else
189
- SECTIONS="$section"
190
- fi
340
+ # ── Finalize ────────────────────────────────────────────────────────────────────
341
+ # Every section was flushed as it resolved (emit_section). Here we only close
342
+ # out the chosen sink. An all-empty briefing wrote nothing — leave it that way
343
+ # so start.sh skips the --append-system-prompt arg.
344
+ if [ -n "$OUTPUT_FILE" ]; then
345
+ # File mode. FILE_STARTED=1 means at least one section was written atomically
346
+ # (header+first) with the rest appended; add the trailing newline the
347
+ # single-shot writer used to emit. If nothing was written, no file exists.
348
+ if [ "$FILE_STARTED" = "1" ]; then
349
+ printf '\n' >> "$OUTPUT_FILE"
191
350
  fi
192
- done
193
-
194
- # Empty briefing — nothing to inject
195
- if [ -z "$SECTIONS" ]; then
196
- exit 0
197
- fi
198
-
199
- BRIEFING="You just restarted at ${TIMESTAMP}. Previous session ended via: ${RESTART_REASON}. Consult this briefing before responding.
200
-
201
- ---
202
-
203
- ${SECTIONS}"
204
-
205
- # ── Output ──────────────────────────────────────────────────────────────────────
206
- if [ "$STDOUT_MODE" = "1" ]; then
207
- printf '%s\n' "$BRIEFING"
208
351
  else
209
- if [ -z "$AGENT_DIR" ]; then
210
- # Fallback: write to stdout if AGENT_DIR is not set
211
- printf '%s\n' "$BRIEFING"
212
- exit 0
352
+ # stdout / no-AGENT_DIR mode — buffered; print the whole briefing once.
353
+ if [ -n "$STDOUT_BUFFER" ]; then
354
+ printf '%s\n\n---\n\n%s\n' "$BRIEFING_HEADER" "$STDOUT_BUFFER"
213
355
  fi
214
- OUTPUT_FILE="$AGENT_DIR/.handoff-briefing.md"
215
- OUTPUT_TMP="${OUTPUT_FILE}.tmp.$$"
216
- printf '%s\n' "$BRIEFING" > "$OUTPUT_TMP" && mv -f "$OUTPUT_TMP" "$OUTPUT_FILE"
217
356
  fi
218
357
 
219
358
  exit 0
@@ -11362,7 +11362,7 @@ var BuzzChannelSchema = exports_external.object({
11362
11362
  default_channel_id: exports_external.string().describe("Relay-minted group UUID (the NIP-29 `h` tag) the sidecar subscribes " + "to and stamps on injected turns."),
11363
11363
  channel_map: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional map of extra group UUIDs → friendly labels."),
11364
11364
  pubkey_names: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional petnames: hex/npub pubkey → display name, used to label " + "the sender on injected turns."),
11365
- pinned_relay_digest: exports_external.string().optional().describe("Pinned relay image digest (M4). The compat-check warns on mismatch; " + "advisory in Phase 1.")
11365
+ pinned_relay_digest: exports_external.string().optional().describe("Pinned relay image digest (M4). RESERVED — no consumer of this field " + "exists; the existing compat-check (compat-check.ts) validates only " + "the wire contract (AUTH kind, message kind, tag names) and does not " + "read this field. Kept in the schema so the intended digest-pin can " + "be wired without a config shape change.")
11366
11366
  }).strict();
11367
11367
  var ChannelsSchema = exports_external.object({
11368
11368
  telegram: TelegramChannelSchema,
@@ -11387,7 +11387,7 @@ var init_schema = __esm(() => {
11387
11387
  default_channel_id: exports_external.string().describe("Relay-minted group UUID (the NIP-29 `h` tag) the sidecar subscribes " + "to and stamps on injected turns."),
11388
11388
  channel_map: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional map of extra group UUIDs → friendly labels."),
11389
11389
  pubkey_names: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional petnames: hex/npub pubkey → display name, used to label " + "the sender on injected turns."),
11390
- pinned_relay_digest: exports_external.string().optional().describe("Pinned relay image digest (M4). The compat-check warns on mismatch; " + "advisory in Phase 1.")
11390
+ pinned_relay_digest: exports_external.string().optional().describe("Pinned relay image digest (M4). RESERVED — no consumer of this field " + "exists; the existing compat-check (compat-check.ts) validates only " + "the wire contract (AUTH kind, message kind, tag names) and does not " + "read this field. Kept in the schema so the intended digest-pin can " + "be wired without a config shape change.")
11391
11391
  }).strict();
11392
11392
  ChannelsSchema = exports_external.object({
11393
11393
  telegram: TelegramChannelSchema,
@@ -1,5 +1,5 @@
1
1
  // src/buzz-gateway/index.ts
2
- import { join as join2 } from "node:path";
2
+ import { join as join3 } from "node:path";
3
3
 
4
4
  // node_modules/.bun/@noble+hashes@2.0.1/node_modules/@noble/hashes/utils.js
5
5
  /*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */
@@ -8874,6 +8874,20 @@ async function publishOutbound(req, secretKey, transport, nowSec) {
8874
8874
  }
8875
8875
  return { ok: true, eventId: signed.eventId };
8876
8876
  }
8877
+ async function publishOutboundTallied(req, secretKey, transport, tally, nowSec) {
8878
+ let result;
8879
+ try {
8880
+ result = await publishOutbound(req, secretKey, transport, nowSec);
8881
+ } catch {
8882
+ tally.failed += 1;
8883
+ return { ok: false, error: "publish failed: transport threw" };
8884
+ }
8885
+ if (result.ok)
8886
+ tally.ok += 1;
8887
+ else
8888
+ tally.failed += 1;
8889
+ return result;
8890
+ }
8877
8891
 
8878
8892
  // src/buzz-gateway/inbound-map.ts
8879
8893
  var BUZZ_MESSAGE_KIND = 9;
@@ -8898,6 +8912,22 @@ function resolveThreadRoot(ev) {
8898
8912
  return eTags[0][1];
8899
8913
  return ev.id;
8900
8914
  }
8915
+ function resolveReplyParent(ev) {
8916
+ const eTags = ev.tags.filter((t) => t[0] === "e" && typeof t[1] === "string");
8917
+ if (eTags.length === 0)
8918
+ return;
8919
+ const hasMarkers = eTags.some((t) => t[3] === "root" || t[3] === "reply" || t[3] === "mention");
8920
+ if (hasMarkers) {
8921
+ const reply = eTags.find((t) => t[3] === "reply");
8922
+ if (reply)
8923
+ return reply[1];
8924
+ const root = eTags.find((t) => t[3] === "root");
8925
+ if (root)
8926
+ return root[1];
8927
+ return;
8928
+ }
8929
+ return eTags[eTags.length - 1][1];
8930
+ }
8901
8931
  function resolveChannelId(ev, fallback) {
8902
8932
  const h = ev.tags.find((t) => t[0] === "h" && typeof t[1] === "string");
8903
8933
  return h ? h[1] : fallback;
@@ -8907,14 +8937,16 @@ function mapBuzzEvent(ev, ctx) {
8907
8937
  return null;
8908
8938
  const channelId = resolveChannelId(ev, ctx.groupId);
8909
8939
  const threadRoot = resolveThreadRoot(ev);
8940
+ const replyTo = resolveReplyParent(ev);
8910
8941
  const user = senderLabel(ev.pubkey, ctx.pubkeyNames);
8911
- const text = `<channel source="buzz" ` + `buzz_channel_id="${escapeAttr(channelId)}" ` + `buzz_event_id="${escapeAttr(ev.id)}" ` + `buzz_pubkey="${escapeAttr(ev.pubkey)}" ` + `buzz_thread_root="${escapeAttr(threadRoot)}" ` + `user="${escapeAttr(user)}">` + escapeBody(ev.content) + `</channel>`;
8942
+ const text = `<channel source="buzz" ` + `buzz_channel_id="${escapeAttr(channelId)}" ` + `buzz_event_id="${escapeAttr(ev.id)}" ` + `buzz_pubkey="${escapeAttr(ev.pubkey)}" ` + `buzz_thread_root="${escapeAttr(threadRoot)}" ` + (replyTo !== undefined ? `buzz_reply_to="${escapeAttr(replyTo)}" ` : "") + `user="${escapeAttr(user)}">` + escapeBody(ev.content) + `</channel>`;
8912
8943
  const meta = {
8913
8944
  source: "buzz",
8914
8945
  buzz_channel_id: channelId,
8915
8946
  buzz_event_id: ev.id,
8916
8947
  buzz_pubkey: ev.pubkey,
8917
8948
  buzz_thread_root: threadRoot,
8949
+ ...replyTo !== undefined ? { buzz_reply_to: replyTo } : {},
8918
8950
  user
8919
8951
  };
8920
8952
  return {
@@ -9052,6 +9084,110 @@ function createRetryQueue(deps) {
9052
9084
  };
9053
9085
  }
9054
9086
 
9087
+ // src/buzz-gateway/heartbeat.ts
9088
+ import {
9089
+ mkdirSync as realMkdirSync,
9090
+ writeFileSync as realWriteFileSync
9091
+ } from "node:fs";
9092
+ import { dirname as dirname2, join as join2 } from "node:path";
9093
+ var BUZZ_HEARTBEAT_SUBDIR = "buzz";
9094
+ var BUZZ_HEARTBEAT_FILE = "buzz-sidecar.heartbeat.json";
9095
+ var BUZZ_HEARTBEAT_INTERVAL_MS = 60 * 1000;
9096
+ var BUZZ_HEARTBEAT_STALE_MULTIPLIER = 3;
9097
+ var BUZZ_HEARTBEAT_STALE_MS = BUZZ_HEARTBEAT_INTERVAL_MS * BUZZ_HEARTBEAT_STALE_MULTIPLIER;
9098
+ var BUZZ_HEARTBEAT_MAX_INTERVAL_MS = BUZZ_HEARTBEAT_STALE_MS / BUZZ_HEARTBEAT_STALE_MULTIPLIER;
9099
+ function resolveStatsIntervalMs(raw) {
9100
+ const requested = Number(raw);
9101
+ if (!Number.isFinite(requested) || requested <= 0) {
9102
+ return BUZZ_HEARTBEAT_INTERVAL_MS;
9103
+ }
9104
+ return Math.min(requested, BUZZ_HEARTBEAT_MAX_INTERVAL_MS);
9105
+ }
9106
+ function buzzHeartbeatStatePath(stateDir) {
9107
+ return join2(stateDir, BUZZ_HEARTBEAT_SUBDIR, BUZZ_HEARTBEAT_FILE);
9108
+ }
9109
+ function writeBuzzHeartbeat(path, hb, io = {}) {
9110
+ const mkdir = io.mkdirSync ?? realMkdirSync;
9111
+ const write = io.writeFileSync ?? realWriteFileSync;
9112
+ mkdir(dirname2(path), { recursive: true });
9113
+ write(path, JSON.stringify(hb));
9114
+ }
9115
+
9116
+ // src/buzz-gateway/stats.ts
9117
+ var REJECT_PREFIX = "rejected:";
9118
+ function summarizePipeline(pumpStats, mirror) {
9119
+ let received = 0;
9120
+ let authFailures = 0;
9121
+ for (const [key, count] of Object.entries(pumpStats)) {
9122
+ received += count;
9123
+ if (key.startsWith(REJECT_PREFIX))
9124
+ authFailures += count;
9125
+ }
9126
+ return {
9127
+ received,
9128
+ injected: pumpStats.injected ?? 0,
9129
+ duplicate: pumpStats.duplicate ?? 0,
9130
+ queued: pumpStats.queued ?? 0,
9131
+ injectFailed: pumpStats.inject_failed ?? 0,
9132
+ droppedByKind: pumpStats.unmapped ?? 0,
9133
+ channelOff: pumpStats.channel_off ?? 0,
9134
+ authFailures,
9135
+ mirrorOk: mirror.ok,
9136
+ mirrorFailed: mirror.failed
9137
+ };
9138
+ }
9139
+ function formatStatsLine(s) {
9140
+ return `buzz stats: received=${s.received} injected=${s.injected} ` + `duplicate=${s.duplicate} queued=${s.queued} inject_failed=${s.injectFailed} ` + `dropped_by_kind=${s.droppedByKind} channel_off=${s.channelOff} ` + `auth_failures=${s.authFailures} mirror_ok=${s.mirrorOk} mirror_failed=${s.mirrorFailed}`;
9141
+ }
9142
+ function createStatsReporter(deps) {
9143
+ const intervalMs = deps.intervalMs && deps.intervalMs > 0 ? deps.intervalMs : 60000;
9144
+ const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
9145
+ const clearTimer = deps.clearTimer ?? ((t) => clearTimeout(t));
9146
+ let timer = null;
9147
+ let stopped = false;
9148
+ let lastLine = null;
9149
+ function tick() {
9150
+ const sample = deps.sample();
9151
+ const line = formatStatsLine(sample.summary);
9152
+ if (line !== lastLine) {
9153
+ deps.emit(line);
9154
+ lastLine = line;
9155
+ }
9156
+ try {
9157
+ deps.persist?.(sample);
9158
+ } catch {}
9159
+ }
9160
+ function schedule() {
9161
+ if (stopped)
9162
+ return;
9163
+ timer = setTimer(() => {
9164
+ timer = null;
9165
+ if (stopped)
9166
+ return;
9167
+ tick();
9168
+ schedule();
9169
+ }, intervalMs);
9170
+ if (timer && typeof timer.unref === "function") {
9171
+ timer.unref();
9172
+ }
9173
+ }
9174
+ return {
9175
+ start() {
9176
+ stopped = false;
9177
+ tick();
9178
+ schedule();
9179
+ },
9180
+ stop() {
9181
+ stopped = true;
9182
+ if (timer !== null) {
9183
+ clearTimer(timer);
9184
+ timer = null;
9185
+ }
9186
+ },
9187
+ tick
9188
+ };
9189
+ }
9190
+
9055
9191
  // src/buzz-gateway/index.ts
9056
9192
  function log(msg) {
9057
9193
  process.stderr.write(`buzz-gateway: ${msg}
@@ -9123,8 +9259,8 @@ async function main() {
9123
9259
  const agentPubkey = getPublicKey(secretKey).toLowerCase();
9124
9260
  log(`booted agent=${config.agentName} relay=${config.relayUrl} group=${config.groupId} allowlist=${config.authorized.size}`);
9125
9261
  const stateDir = process.env.TELEGRAM_STATE_DIR ?? "/state/agent/telegram";
9126
- const socketPath = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join2(stateDir, "gateway.sock");
9127
- const journalPath = process.env.BUZZ_JOURNAL_PATH ?? join2(stateDir, "buzz", "journal.jsonl");
9262
+ const socketPath = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join3(stateDir, "gateway.sock");
9263
+ const journalPath = process.env.BUZZ_JOURNAL_PATH ?? join3(stateDir, "buzz", "journal.jsonl");
9128
9264
  const dedup = createDedupStore({ journalPath, log });
9129
9265
  const ipcClient = createInjectIpcClient({ socketPath, log });
9130
9266
  const inject = makeInject(ipcClient, config.agentName);
@@ -9158,16 +9294,17 @@ async function main() {
9158
9294
  log
9159
9295
  });
9160
9296
  const publishTransport = (event, timeoutMs) => nostr.publish(event, timeoutMs);
9297
+ const mirror = { ok: 0, failed: 0 };
9161
9298
  const buzzPeer = createBuzzPeerClient({
9162
9299
  socketPath,
9163
9300
  agentName: config.agentName,
9164
9301
  onOutbound: async (req) => {
9165
- const result = await publishOutbound({
9302
+ const result = await publishOutboundTallied({
9166
9303
  channelId: req.channelId,
9167
9304
  replyToEventId: req.replyToEventId,
9168
9305
  threadRootId: req.threadRootId,
9169
9306
  payload: req.payload
9170
- }, secretKey, publishTransport);
9307
+ }, secretKey, publishTransport, mirror);
9171
9308
  return {
9172
9309
  type: "buzz_publish_result",
9173
9310
  correlationId: req.correlationId,
@@ -9178,8 +9315,30 @@ async function main() {
9178
9315
  },
9179
9316
  log
9180
9317
  });
9318
+ const bootTs = Date.now();
9319
+ const heartbeatPath = buzzHeartbeatStatePath(stateDir);
9320
+ const statsIntervalMs = resolveStatsIntervalMs(process.env.BUZZ_STATS_INTERVAL_MS);
9321
+ const statsReporter = createStatsReporter({
9322
+ intervalMs: statsIntervalMs,
9323
+ sample: () => ({
9324
+ summary: summarizePipeline(pump.stats, mirror),
9325
+ subscribed: nostr.isSubscribed()
9326
+ }),
9327
+ emit: (line) => log(line),
9328
+ persist: (sample) => writeBuzzHeartbeat(heartbeatPath, {
9329
+ v: 1,
9330
+ agent: config.agentName,
9331
+ ts: Date.now(),
9332
+ bootTs,
9333
+ subscribed: sample.subscribed,
9334
+ stats: sample.summary
9335
+ })
9336
+ });
9181
9337
  const shutdown = () => {
9182
9338
  log("shutting down");
9339
+ try {
9340
+ statsReporter.stop();
9341
+ } catch {}
9183
9342
  try {
9184
9343
  nostr.stop();
9185
9344
  } catch {}
@@ -9200,6 +9359,7 @@ async function main() {
9200
9359
  process.on("SIGTERM", shutdown);
9201
9360
  process.on("SIGINT", shutdown);
9202
9361
  nostr.start();
9362
+ statsReporter.start();
9203
9363
  }
9204
9364
  main().catch((err) => {
9205
9365
  log(`FATAL: ${err.message}`);
@@ -12123,7 +12123,7 @@ var BuzzChannelSchema = exports_external.object({
12123
12123
  default_channel_id: exports_external.string().describe("Relay-minted group UUID (the NIP-29 `h` tag) the sidecar subscribes " + "to and stamps on injected turns."),
12124
12124
  channel_map: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional map of extra group UUIDs \u2192 friendly labels."),
12125
12125
  pubkey_names: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional petnames: hex/npub pubkey \u2192 display name, used to label " + "the sender on injected turns."),
12126
- pinned_relay_digest: exports_external.string().optional().describe("Pinned relay image digest (M4). The compat-check warns on mismatch; " + "advisory in Phase 1.")
12126
+ pinned_relay_digest: exports_external.string().optional().describe("Pinned relay image digest (M4). RESERVED \u2014 no consumer of this field " + "exists; the existing compat-check (compat-check.ts) validates only " + "the wire contract (AUTH kind, message kind, tag names) and does not " + "read this field. Kept in the schema so the intended digest-pin can " + "be wired without a config shape change.")
12127
12127
  }).strict();
12128
12128
  var ChannelsSchema = exports_external.object({
12129
12129
  telegram: TelegramChannelSchema,