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.
- package/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +1 -1
- package/dist/buzz-gateway/index.js +166 -6
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +24701 -16397
- package/dist/host-control/main.js +41 -8
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -10
- package/telegram-plugin/dist/gateway/gateway.js +1397 -962
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +135 -7
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
- package/telegram-plugin/gateway/gateway.ts +43 -123
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
- package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
package/bin/handoff-briefing.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
|
65
|
-
#
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
#
|
|
139
|
-
#
|
|
140
|
-
# UTC here would look up the wrong day's file
|
|
141
|
-
# date is ahead of/behind UTC, silently
|
|
142
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
printf '%s\n' "$
|
|
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).
|
|
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).
|
|
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
|
|
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 ??
|
|
9127
|
-
const journalPath = process.env.BUZZ_JOURNAL_PATH ??
|
|
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
|
|
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).
|
|
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,
|