switchroom 0.19.48 → 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 (60) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +18 -1
  3. package/dist/auth-broker/index.js +19 -2
  4. package/dist/buzz-gateway/index.js +9367 -0
  5. package/dist/cli/notion-write-pretool.mjs +18 -1
  6. package/dist/cli/switchroom.js +24734 -16371
  7. package/dist/host-control/main.js +59 -9
  8. package/dist/vault/approvals/kernel-server.js +19 -2
  9. package/dist/vault/broker/server.js +19 -2
  10. package/package.json +6 -4
  11. package/profiles/_base/start.sh.hbs +148 -2
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/dev-protocol/SKILL.md +30 -1
  14. package/skills/switchroom-architecture/SKILL.md +5 -0
  15. package/skills/switchroom-cli/SKILL.md +1 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  17. package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
  18. package/telegram-plugin/dist/server.js +7 -4
  19. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  20. package/telegram-plugin/gateway/access-store.ts +194 -0
  21. package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
  22. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  23. package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
  24. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  25. package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
  26. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  27. package/telegram-plugin/gateway/channel-route.ts +272 -0
  28. package/telegram-plugin/gateway/gateway.ts +115 -203
  29. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  30. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  31. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  32. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  33. package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
  34. package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
  35. package/telegram-plugin/gateway/stream-render.ts +21 -0
  36. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  37. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  38. package/telegram-plugin/history.ts +15 -0
  39. package/telegram-plugin/llm-error-present.ts +9 -4
  40. package/telegram-plugin/model-unavailable.ts +4 -0
  41. package/telegram-plugin/operator-events.fixtures.json +12 -12
  42. package/telegram-plugin/operator-events.ts +81 -9
  43. package/telegram-plugin/session-tail.ts +7 -1
  44. package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
  45. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  46. package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
  47. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  48. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  49. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  50. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  51. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  52. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  53. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  54. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  55. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  56. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
  57. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  58. package/telegram-plugin/voice-normalize-text.ts +5 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  60. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -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
@@ -11216,6 +11216,7 @@ var SessionContinuitySchema = exports_external.object({
11216
11216
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
11217
11217
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
11218
11218
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale."),
11219
+ briefing: exports_external.enum(["gateway", "legacy"]).optional().describe("Which mechanism assembles the fresh-session reorientation briefing " + "(default 'legacy'). 'legacy' keeps today's behaviour: the Stop-hook " + ".handoff.md and/or bin/handoff-briefing.sh, injected via " + "--append-system-prompt. 'gateway' moves it to a gateway boot-time " + "builder sourced from the durable history.db (crash-independent, " + "surface-scoped, token-budgeted) and injects it as a synthetic " + '<channel source="boot_briefing"> inbound over the durable spool — ' + "keeping the system-prompt prefix stable for cross-session prompt " + "caching. Suppressed automatically when resume_mode is " + "'continue'/'auto' (the transcript may be replayed) and on a /reset " + "force-fresh boot. Threaded to the gateway as " + "SWITCHROOM_SESSION_BRIEFING."),
11219
11220
  boot_resume: exports_external.enum(["always", "in-flight", "never"]).optional().describe("How the gateway auto-resumes a turn that was IN FLIGHT when the " + "agent restarted. 'in-flight' (default) resumes genuinely " + "interrupted work even after a deliberate/operator restart — a " + "sanctioned restart landing mid-turn no longer silently drops the " + "work. 'always' forces resume unconditionally (same as the " + "SWITCHROOM_BOOT_RESUME_ALWAYS=1 escape hatch). 'never' is the " + "quota-saving posture: don't auto-replay work across a clean " + "restart — but the user is STILL sent a passive notice of what was " + "in flight (silence is never used). Independent of the at-most-once " + "resume ledger and the bounded resume-chain loop-guard, which always " + "apply. Threaded to the gateway as SWITCHROOM_BOOT_RESUME."),
11220
11221
  session_retention_max_count: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): keep at most this many " + "newest session transcripts under .claude/projects; older ones " + "past both this count and the age bound are pruned by the Stop " + "hook. The newest sessions (and the handoff source) are always " + "kept. Default 20; set 0 to disable the count bound."),
11221
11222
  session_retention_max_age_days: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): prune session transcripts " + "older than this many days (a file is deleted only when it is BOTH " + "over the count bound and older than this). Default 30; set 0 to " + "disable the age bound.")
@@ -11348,8 +11349,24 @@ var TelegramChannelSchema = exports_external.object({
11348
11349
  }
11349
11350
  return tg;
11350
11351
  });
11352
+ var BuzzChannelSchema = exports_external.object({
11353
+ enabled: exports_external.boolean().default(false).describe("Master switch for the per-agent Buzz sidecar. Default false — the " + "channel ships dark; start.sh forks the sidecar only when true."),
11354
+ relay_url: exports_external.string().regex(/^wss?:\/\//, "relay_url must be a ws:// or wss:// URL").describe("CANONICAL WebSocket URL of the closed Buzz relay — the exact string " + "the relay expects in the NIP-42 `relay` auth tag (e.g. " + "'ws://127.0.0.1:3000'). A live probe proved the relay validates this " + "tag as an exact string match against its own URL BEFORE the " + "membership check, so it is the relay's advertised identity, NOT " + "necessarily the address the sidecar dials. Set relay_dial_url when " + "the reachable address differs (a docker-network IP)."),
11355
+ relay_dial_url: exports_external.string().regex(/^wss?:\/\//, "relay_dial_url must be a ws:// or wss:// URL").optional().describe("Reachable ws:// / wss:// address the sidecar DIALS when it differs " + "from the canonical relay_url (e.g. a docker-network IP the relay's " + "own 127.0.0.1 can't stand in for). The NIP-42 auth tag still uses " + "relay_url. Defaults to relay_url when unset."),
11356
+ relay_host: exports_external.string().regex(/^(\[[0-9a-fA-F:]+\]|[^\s/?#:@]+)(:\d+)?$/, "relay_host must be a bare host[:port] authority — no scheme, path, or userinfo (e.g. '127.0.0.1:3000')").describe("REQUIRED HTTP Host header authority sent verbatim on the WS upgrade " + "(e.g. '127.0.0.1:3000', port included). The relay resolves its " + "community from this header before the upgrade and returns HTTP 404 if " + "it is missing/wrong, so it must match the relay's configured " + "authority and is deployment config, never derived from the dial URL."),
11357
+ nsec_vault_key: exports_external.string().default("buzz/{agent}-nsec").describe("Vault KEY NAME for the agent's Nostr secret key. Broker-fetched " + "in-process at sidecar boot; NEVER resolved into env or logged. " + "'{agent}' is substituted with the agent name."),
11358
+ operator_pubkey: exports_external.string().regex(/^(npub1[02-9ac-hj-np-z]{58}|[0-9a-f]{64})$/, "operator_pubkey must be a bech32 npub or 64-char hex pubkey").describe("The operator's Nostr pubkey (npub or hex). Always in the effective " + "inbound allowlist — the fail-closed default is operator-only."),
11359
+ authorized_pubkeys: exports_external.array(exports_external.string()).default([]).describe("Additional pubkeys (npub or hex) whose signed events may become " + "turns. Effective allowlist = this ∪ {operator_pubkey}. Empty by " + "default (operator-only)."),
11360
+ mirror: exports_external.enum(["both", "origin", "off"]).default("both").describe("Cross-surface mirror mode. 'both' answers on the origin channel AND " + "mirrors a copy to the other; 'off' is a true kill-switch that disables " + "the channel in BOTH directions (the inbound sidecar exits idle). " + "Phase 2b (S2): 'origin' is DEFERRED — the hub's mirror hook lives only " + "in sendReply, so 'origin' cannot be honored soundly; a configured " + "'origin' is degraded to 'off' (dark) at runtime by both the sidecar " + "config loader and the hub (channel-route.ts parseConfiguredMirrorMode). " + "Only 'both' and 'off' ship live in 2b."),
11361
+ chat_id: exports_external.string().min(1, "chat_id must be a non-empty Telegram chat id").describe("Telegram chat id an injected Buzz turn is routed to. Phase 1 is " + "inbound-only, so the agent's reply lands here on Telegram (the " + "authoritative surface); in later phases this is the chat the Buzz " + "turn's Telegram copy maps to. Required — the sidecar refuses to run " + "live without it (BUZZ_CHAT_ID)."),
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
+ channel_map: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional map of extra group UUIDs → friendly labels."),
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). 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
+ }).strict();
11351
11367
  var ChannelsSchema = exports_external.object({
11352
- telegram: TelegramChannelSchema
11368
+ telegram: TelegramChannelSchema,
11369
+ buzz: BuzzChannelSchema.optional()
11353
11370
  }).optional();
11354
11371
  var TIMEZONE_REGEX = /^UTC$|^[A-Z][A-Za-z0-9_+-]+(\/[A-Z][A-Za-z0-9_+-]+){1,2}$/;
11355
11372
  var ApproverIdSchema = exports_external.union([exports_external.number(), exports_external.string().regex(/^\d+$/)]);
@@ -10994,7 +10994,7 @@ var init_observation_scopes = __esm(() => {
10994
10994
  });
10995
10995
 
10996
10996
  // src/config/schema.ts
10997
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
10997
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
10998
10998
  var init_schema = __esm(() => {
10999
10999
  init_zod();
11000
11000
  init_observation_scopes();
@@ -11241,6 +11241,7 @@ var init_schema = __esm(() => {
11241
11241
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
11242
11242
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
11243
11243
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale."),
11244
+ briefing: exports_external.enum(["gateway", "legacy"]).optional().describe("Which mechanism assembles the fresh-session reorientation briefing " + "(default 'legacy'). 'legacy' keeps today's behaviour: the Stop-hook " + ".handoff.md and/or bin/handoff-briefing.sh, injected via " + "--append-system-prompt. 'gateway' moves it to a gateway boot-time " + "builder sourced from the durable history.db (crash-independent, " + "surface-scoped, token-budgeted) and injects it as a synthetic " + '<channel source="boot_briefing"> inbound over the durable spool — ' + "keeping the system-prompt prefix stable for cross-session prompt " + "caching. Suppressed automatically when resume_mode is " + "'continue'/'auto' (the transcript may be replayed) and on a /reset " + "force-fresh boot. Threaded to the gateway as " + "SWITCHROOM_SESSION_BRIEFING."),
11244
11245
  boot_resume: exports_external.enum(["always", "in-flight", "never"]).optional().describe("How the gateway auto-resumes a turn that was IN FLIGHT when the " + "agent restarted. 'in-flight' (default) resumes genuinely " + "interrupted work even after a deliberate/operator restart — a " + "sanctioned restart landing mid-turn no longer silently drops the " + "work. 'always' forces resume unconditionally (same as the " + "SWITCHROOM_BOOT_RESUME_ALWAYS=1 escape hatch). 'never' is the " + "quota-saving posture: don't auto-replay work across a clean " + "restart — but the user is STILL sent a passive notice of what was " + "in flight (silence is never used). Independent of the at-most-once " + "resume ledger and the bounded resume-chain loop-guard, which always " + "apply. Threaded to the gateway as SWITCHROOM_BOOT_RESUME."),
11245
11246
  session_retention_max_count: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): keep at most this many " + "newest session transcripts under .claude/projects; older ones " + "past both this count and the age bound are pruned by the Stop " + "hook. The newest sessions (and the handoff source) are always " + "kept. Default 20; set 0 to disable the count bound."),
11246
11247
  session_retention_max_age_days: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): prune session transcripts " + "older than this many days (a file is deleted only when it is BOTH " + "over the count bound and older than this). Default 30; set 0 to " + "disable the age bound.")
@@ -11373,8 +11374,24 @@ var init_schema = __esm(() => {
11373
11374
  }
11374
11375
  return tg;
11375
11376
  });
11377
+ BuzzChannelSchema = exports_external.object({
11378
+ enabled: exports_external.boolean().default(false).describe("Master switch for the per-agent Buzz sidecar. Default false — the " + "channel ships dark; start.sh forks the sidecar only when true."),
11379
+ relay_url: exports_external.string().regex(/^wss?:\/\//, "relay_url must be a ws:// or wss:// URL").describe("CANONICAL WebSocket URL of the closed Buzz relay — the exact string " + "the relay expects in the NIP-42 `relay` auth tag (e.g. " + "'ws://127.0.0.1:3000'). A live probe proved the relay validates this " + "tag as an exact string match against its own URL BEFORE the " + "membership check, so it is the relay's advertised identity, NOT " + "necessarily the address the sidecar dials. Set relay_dial_url when " + "the reachable address differs (a docker-network IP)."),
11380
+ relay_dial_url: exports_external.string().regex(/^wss?:\/\//, "relay_dial_url must be a ws:// or wss:// URL").optional().describe("Reachable ws:// / wss:// address the sidecar DIALS when it differs " + "from the canonical relay_url (e.g. a docker-network IP the relay's " + "own 127.0.0.1 can't stand in for). The NIP-42 auth tag still uses " + "relay_url. Defaults to relay_url when unset."),
11381
+ relay_host: exports_external.string().regex(/^(\[[0-9a-fA-F:]+\]|[^\s/?#:@]+)(:\d+)?$/, "relay_host must be a bare host[:port] authority — no scheme, path, or userinfo (e.g. '127.0.0.1:3000')").describe("REQUIRED HTTP Host header authority sent verbatim on the WS upgrade " + "(e.g. '127.0.0.1:3000', port included). The relay resolves its " + "community from this header before the upgrade and returns HTTP 404 if " + "it is missing/wrong, so it must match the relay's configured " + "authority and is deployment config, never derived from the dial URL."),
11382
+ nsec_vault_key: exports_external.string().default("buzz/{agent}-nsec").describe("Vault KEY NAME for the agent's Nostr secret key. Broker-fetched " + "in-process at sidecar boot; NEVER resolved into env or logged. " + "'{agent}' is substituted with the agent name."),
11383
+ operator_pubkey: exports_external.string().regex(/^(npub1[02-9ac-hj-np-z]{58}|[0-9a-f]{64})$/, "operator_pubkey must be a bech32 npub or 64-char hex pubkey").describe("The operator's Nostr pubkey (npub or hex). Always in the effective " + "inbound allowlist — the fail-closed default is operator-only."),
11384
+ authorized_pubkeys: exports_external.array(exports_external.string()).default([]).describe("Additional pubkeys (npub or hex) whose signed events may become " + "turns. Effective allowlist = this ∪ {operator_pubkey}. Empty by " + "default (operator-only)."),
11385
+ mirror: exports_external.enum(["both", "origin", "off"]).default("both").describe("Cross-surface mirror mode. 'both' answers on the origin channel AND " + "mirrors a copy to the other; 'off' is a true kill-switch that disables " + "the channel in BOTH directions (the inbound sidecar exits idle). " + "Phase 2b (S2): 'origin' is DEFERRED — the hub's mirror hook lives only " + "in sendReply, so 'origin' cannot be honored soundly; a configured " + "'origin' is degraded to 'off' (dark) at runtime by both the sidecar " + "config loader and the hub (channel-route.ts parseConfiguredMirrorMode). " + "Only 'both' and 'off' ship live in 2b."),
11386
+ chat_id: exports_external.string().min(1, "chat_id must be a non-empty Telegram chat id").describe("Telegram chat id an injected Buzz turn is routed to. Phase 1 is " + "inbound-only, so the agent's reply lands here on Telegram (the " + "authoritative surface); in later phases this is the chat the Buzz " + "turn's Telegram copy maps to. Required — the sidecar refuses to run " + "live without it (BUZZ_CHAT_ID)."),
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
+ channel_map: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional map of extra group UUIDs → friendly labels."),
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). 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
+ }).strict();
11376
11392
  ChannelsSchema = exports_external.object({
11377
- telegram: TelegramChannelSchema
11393
+ telegram: TelegramChannelSchema,
11394
+ buzz: BuzzChannelSchema.optional()
11378
11395
  }).optional();
11379
11396
  TIMEZONE_REGEX = /^UTC$|^[A-Z][A-Za-z0-9_+-]+(\/[A-Z][A-Za-z0-9_+-]+){1,2}$/;
11380
11397
  ApproverIdSchema = exports_external.union([exports_external.number(), exports_external.string().regex(/^\d+$/)]);