switchroom 0.20.9 → 0.20.11
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 +57 -5
- package/bin/working-state-reload-hook.sh +262 -0
- package/dist/agent-scheduler/index.js +65 -2
- package/dist/auth-broker/index.js +204 -24
- package/dist/cli/notion-write-pretool.mjs +65 -2
- package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
- package/dist/cli/self-improve-stop.mjs +889 -7
- package/dist/cli/skill-validate-pretool.mjs +82 -3
- package/dist/cli/switchroom.js +3699 -2110
- package/dist/host-control/main.js +67 -4
- package/dist/vault/approvals/kernel-server.js +66 -3
- package/dist/vault/broker/server.js +66 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +49 -0
- package/profiles/_shared/agent-self-service.md.hbs +15 -22
- package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
- package/profiles/_shared/dev-protocol.md.hbs +1 -1
- package/profiles/_shared/execution-discipline.md.hbs +4 -4
- package/profiles/_shared/vault-protocol.md.hbs +2 -18
- package/profiles/default/CLAUDE.md.hbs +3 -5
- package/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/dist/gateway/gateway.js +1414 -918
- package/telegram-plugin/fallback-card-collapse.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +11 -1
- package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
- package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
- package/telegram-plugin/gateway/gateway.ts +85 -90
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/narrative-lane.ts +33 -2
- package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
- package/telegram-plugin/gateway/privacy-reset.ts +87 -0
- package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
- package/telegram-plugin/gateway/privacy-state.ts +206 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
- package/telegram-plugin/gateway/stale-pin-sweep.ts +48 -32
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
- package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
- package/telegram-plugin/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/vendor/hindsight-memory/CHANGELOG.md +31 -0
- package/vendor/hindsight-memory/hooks/hooks.json +2 -1
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- package/vendor/hindsight-memory/scripts/session_start.py +35 -8
- package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
- package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
- package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
package/bin/handoff-briefing.sh
CHANGED
|
@@ -34,9 +34,32 @@
|
|
|
34
34
|
# - AGENT_DIR — output destination (if HANDOFF_BRIEFING_STDOUT!=1)
|
|
35
35
|
#
|
|
36
36
|
# Usage:
|
|
37
|
-
# handoff-briefing.sh [--stdout]
|
|
37
|
+
# handoff-briefing.sh [--stdout] [--lean]
|
|
38
38
|
#
|
|
39
39
|
# The --stdout flag overrides HANDOFF_BRIEFING_STDOUT=1.
|
|
40
|
+
#
|
|
41
|
+
# LEAN MODE (--lean, alias --mode=compaction)
|
|
42
|
+
# -------------------------------------------
|
|
43
|
+
# The compaction re-seat path (bin/working-state-reload-hook.sh, wired as the
|
|
44
|
+
# SessionStart(compact) hook) invokes this script with --lean so BOTH legacy-
|
|
45
|
+
# and gateway-briefing agents share ONE assembler at the compaction boundary —
|
|
46
|
+
# no copy of the sqlite/recall logic lives in the hook. Lean mode differs from
|
|
47
|
+
# the boot briefing in three deliberate ways:
|
|
48
|
+
#
|
|
49
|
+
# 1. It emits ONLY the recent-Telegram-tail + Hindsight-recall sections.
|
|
50
|
+
# Daily-memory (Source 3) and the "You just restarted at …" header are
|
|
51
|
+
# SKIPPED. Rationale is token duplication, not latency: Claude Code's
|
|
52
|
+
# native compaction summary already preserves the recent turns, so
|
|
53
|
+
# re-injecting the full boot briefing on every compaction of a long
|
|
54
|
+
# session partially triples coverage.
|
|
55
|
+
# 2. It IGNORES the SWITCHROOM_PENDING_* env (boot-time pending-turn scope).
|
|
56
|
+
# Those name the surface that was mid-turn at the PREVIOUS boot; at a
|
|
57
|
+
# compaction hours into a live session they are stale and would brief the
|
|
58
|
+
# WRONG chat surface. Lean mode zeroes them so the python scoper derives
|
|
59
|
+
# the single most-recently-active (chat_id, thread_id) straight from the
|
|
60
|
+
# DB (scope_source=db-latest).
|
|
61
|
+
# 3. It forces stdout and never touches AGENT_DIR (no output file), so it is
|
|
62
|
+
# safe to call from the hook regardless of whether AGENT_DIR is exported.
|
|
40
63
|
|
|
41
64
|
set -u
|
|
42
65
|
|
|
@@ -71,12 +94,32 @@ HINDSIGHT_TIMEOUT="${HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT:-3}"
|
|
|
71
94
|
TARGET_CHAT_ID="${SWITCHROOM_PENDING_CHAT_ID:-}"
|
|
72
95
|
TARGET_THREAD_ID="${SWITCHROOM_PENDING_THREAD_ID:-}"
|
|
73
96
|
|
|
74
|
-
# Determine output mode
|
|
97
|
+
# Determine output + lean mode. Parse every arg (order-independent) so
|
|
98
|
+
# `--lean`, `--stdout`, or both, work regardless of position.
|
|
75
99
|
STDOUT_MODE=0
|
|
76
|
-
|
|
100
|
+
LEAN_MODE=0
|
|
101
|
+
for _arg in "$@"; do
|
|
102
|
+
case "$_arg" in
|
|
103
|
+
--stdout) STDOUT_MODE=1 ;;
|
|
104
|
+
--lean|--mode=compaction) LEAN_MODE=1 ;;
|
|
105
|
+
*) : ;;
|
|
106
|
+
esac
|
|
107
|
+
done
|
|
108
|
+
if [ "${HANDOFF_BRIEFING_STDOUT:-}" = "1" ]; then
|
|
77
109
|
STDOUT_MODE=1
|
|
78
110
|
fi
|
|
79
111
|
|
|
112
|
+
# Lean (compaction) mode: force stdout, and ZERO the pending-turn env scope so
|
|
113
|
+
# the python scoper falls through to db-latest (see LEAN MODE note in header).
|
|
114
|
+
# This is the load-bearing correctness fix for a mid-session compaction: the
|
|
115
|
+
# SWITCHROOM_PENDING_* surface is the previous boot's, not the currently-active
|
|
116
|
+
# chat. Clearing them here (not in the hook) keeps the single scoping code path.
|
|
117
|
+
if [ "$LEAN_MODE" = "1" ]; then
|
|
118
|
+
STDOUT_MODE=1
|
|
119
|
+
TARGET_CHAT_ID=""
|
|
120
|
+
TARGET_THREAD_ID=""
|
|
121
|
+
fi
|
|
122
|
+
|
|
80
123
|
# ── Source 1: Recent Telegram messages ─────────────────────────────────────────
|
|
81
124
|
TELEGRAM_SECTION=""
|
|
82
125
|
if [ -n "$TELEGRAM_STATE" ] && [ -d "$TELEGRAM_STATE" ]; then
|
|
@@ -362,7 +405,9 @@ fi
|
|
|
362
405
|
# dropping today's memory.
|
|
363
406
|
DAILY_SECTION=""
|
|
364
407
|
TODAY=$(TZ="$_TZ_VAL" date +%Y-%m-%d 2>/dev/null || date +%Y-%m-%d 2>/dev/null || true)
|
|
365
|
-
|
|
408
|
+
# Lean/compaction mode SKIPS daily memory (token duplication — the native
|
|
409
|
+
# summary already carries recent context; see LEAN MODE note in header).
|
|
410
|
+
if [ "$LEAN_MODE" != "1" ] && [ -n "$TODAY" ] && [ -n "$WORKSPACE_DIR" ]; then
|
|
366
411
|
DAILY_FILE="$WORKSPACE_DIR/memory/${TODAY}.md"
|
|
367
412
|
if [ -f "$DAILY_FILE" ] && [ -s "$DAILY_FILE" ]; then
|
|
368
413
|
DAILY_CONTENT=$(cat "$DAILY_FILE")
|
|
@@ -391,7 +436,14 @@ if [ -n "$OUTPUT_FILE" ]; then
|
|
|
391
436
|
else
|
|
392
437
|
# stdout / no-AGENT_DIR mode — buffered; print the whole briefing once.
|
|
393
438
|
if [ -n "$STDOUT_BUFFER" ]; then
|
|
394
|
-
|
|
439
|
+
if [ "$LEAN_MODE" = "1" ]; then
|
|
440
|
+
# Lean/compaction: no "You just restarted at …" boot header — this is a
|
|
441
|
+
# mid-conversation compaction, not a restart, and the hook emits its own
|
|
442
|
+
# <compact-recovery> framing. Print only the assembled sections.
|
|
443
|
+
printf '%s\n' "$STDOUT_BUFFER"
|
|
444
|
+
else
|
|
445
|
+
printf '%s\n\n---\n\n%s\n' "$BRIEFING_HEADER" "$STDOUT_BUFFER"
|
|
446
|
+
fi
|
|
395
447
|
fi
|
|
396
448
|
fi
|
|
397
449
|
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# working-state-reload-hook.sh — deliver post-compaction continuation into
|
|
3
|
+
# context immediately after context compaction.
|
|
4
|
+
#
|
|
5
|
+
# WHY THIS EXISTS
|
|
6
|
+
# ---------------
|
|
7
|
+
# Claude Code auto-compacts late in the context window. The native
|
|
8
|
+
# auto-summarizer produces a structured summary of intent/changes/pending
|
|
9
|
+
# work, but it is lossy: fast-moving detail an agent is actively juggling
|
|
10
|
+
# mid-task (a checklist, the current plan, in-flight IDs, the exact "where
|
|
11
|
+
# was I" scratch, recent phrasing) is exactly what a summary flattens or
|
|
12
|
+
# drops. Worse, the model resuming from a summary can read it as a FRESH
|
|
13
|
+
# start and re-greet the user, breaking a conversation the user experiences
|
|
14
|
+
# as unbroken.
|
|
15
|
+
#
|
|
16
|
+
# This hook closes that gap deterministically, in three layers:
|
|
17
|
+
# 1. It ALWAYS emits a short, static recovery/orientation block — for
|
|
18
|
+
# EVERY agent, whether or not it maintains a working-state file. This
|
|
19
|
+
# is the load-bearing default: it tells the model its context was just
|
|
20
|
+
# compacted mid-conversation, that the native summary is lossy, and
|
|
21
|
+
# which concrete recovery tools exist in this environment.
|
|
22
|
+
# 2. If the agent maintains a working-state file AND it is non-empty, the
|
|
23
|
+
# hook additionally appends that file verbatim (with its last-modified
|
|
24
|
+
# time, so a stale/forgotten file is visibly stale rather than silently
|
|
25
|
+
# steering).
|
|
26
|
+
# 3. It emits a LEAN briefing (P1) — a scoped recent Telegram tail + a
|
|
27
|
+
# Hindsight recall — so the compacted session gets fresh-boot PARITY:
|
|
28
|
+
# it picks up the actual conversation, not just the fact it was
|
|
29
|
+
# compacted. This is delegated to handoff-briefing.sh --lean (the SINGLE
|
|
30
|
+
# briefing assembler; no copy of the sqlite/recall logic here), so BOTH
|
|
31
|
+
# legacy- and gateway-briefing agents share one compaction re-seat path.
|
|
32
|
+
# Lean by design: it skips daily-memory/workspace re-render (token
|
|
33
|
+
# duplication — the native summary already keeps recent turns) and
|
|
34
|
+
# ignores SWITCHROOM_PENDING_* to brief the db-latest chat surface rather
|
|
35
|
+
# than a stale pending-turn one. Graceful: if history.db or Hindsight is
|
|
36
|
+
# unavailable it emits what it can (or nothing) and never fails the hook.
|
|
37
|
+
#
|
|
38
|
+
# It is wired as a SessionStart hook with matcher "compact" (see
|
|
39
|
+
# src/agents/scaffold.ts buildSettingsHooksBlock). Per Claude Code's hook
|
|
40
|
+
# contract:
|
|
41
|
+
# - SessionStart fires with source="compact" on auto OR manual compaction,
|
|
42
|
+
# mid-turn, right after the compaction boundary.
|
|
43
|
+
# - Text a SessionStart hook prints to stdout IS added to the model's
|
|
44
|
+
# context (unlike PreCompact stdout, which is NOT injected).
|
|
45
|
+
# So printing here re-seats orientation (and any working state) into context
|
|
46
|
+
# the instant the summary replaces the transcript — no marker file, no
|
|
47
|
+
# gateway round-trip, no waiting for the next user message.
|
|
48
|
+
#
|
|
49
|
+
# The matcher "compact" is load-bearing: it scopes this hook to compaction
|
|
50
|
+
# ONLY. A bare (matcher-less) SessionStart also fires on "startup", "resume",
|
|
51
|
+
# "clear", and "fork", which would inject the recovery block on every boot —
|
|
52
|
+
# noise, and prompt-cache churn. We rely on the matcher AND, belt-and-braces,
|
|
53
|
+
# re-check the `source` field from stdin below so a future Claude Code matcher
|
|
54
|
+
# regression can never turn this into an every-boot inject.
|
|
55
|
+
#
|
|
56
|
+
# THE WORKING-STATE FILE CONVENTION
|
|
57
|
+
# ---------------------------------
|
|
58
|
+
# $TELEGRAM_STATE_DIR/.working-state.md
|
|
59
|
+
# i.e. <agentDir>/telegram/.working-state.md (TELEGRAM_STATE_DIR is exported
|
|
60
|
+
# by start.sh as "<agentDir>/telegram"). An agent maintains this file itself
|
|
61
|
+
# as its durable scratch of "what I'm mid-way through". If the file is absent
|
|
62
|
+
# or empty — the common case for agents that don't use it — the hook simply
|
|
63
|
+
# skips the append; the static recovery block is still emitted. No file is
|
|
64
|
+
# ever created here.
|
|
65
|
+
#
|
|
66
|
+
# PERFORMANCE
|
|
67
|
+
# -----------
|
|
68
|
+
# The static recovery block and working-state append are local-only (a heredoc
|
|
69
|
+
# plus at most one `stat`/`cat` of a small file) — sub-second. The lean
|
|
70
|
+
# briefing (layer 3) adds one local SQLite read and ONE network hop to
|
|
71
|
+
# Hindsight, which handoff-briefing.sh caps (HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT,
|
|
72
|
+
# default 3s). Worst-case runtime is therefore a few seconds, dominated by that
|
|
73
|
+
# cap; the SessionStart(compact) hook's Claude Code timeout is set accordingly
|
|
74
|
+
# in src/agents/scaffold.ts. Latency here is a non-issue by design: compaction
|
|
75
|
+
# itself takes far longer, and the prompt cache is already invalidated by the
|
|
76
|
+
# summary replacing the transcript. (Contrast the hindsight session_start.py
|
|
77
|
+
# SessionStart hook, which times out at its 5s budget on every firing — a
|
|
78
|
+
# separate, independent context-loss cause tracked against the hindsight-memory
|
|
79
|
+
# plugin, NOT fixed here.)
|
|
80
|
+
#
|
|
81
|
+
# Failure modes are all silent: a hook that errors would surface on the issues
|
|
82
|
+
# card via run-hook.sh, but a missing/absent working-state file, an unreadable
|
|
83
|
+
# mtime, a missing history.db, or an unreachable Hindsight is never an error —
|
|
84
|
+
# the recovery block still emits and the hook exits 0.
|
|
85
|
+
|
|
86
|
+
set -u
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Defensive source guard. The matcher "compact" in settings.json already
|
|
90
|
+
# scopes Claude Code to fire this hook only on compaction, but we re-verify
|
|
91
|
+
# the source from the hook's stdin JSON so a matcher regression (or a manual
|
|
92
|
+
# mis-wire) can never cause this to inject on a normal startup/resume/clear/
|
|
93
|
+
# fork boot. If stdin carries a `source` and it is not "compact", exit
|
|
94
|
+
# silently. If there is no stdin (e.g. a unit test invoking the script
|
|
95
|
+
# directly), fall through and trust the matcher.
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
if ! [ -t 0 ]; then
|
|
98
|
+
STDIN_JSON=$(cat 2>/dev/null || true)
|
|
99
|
+
if [ -n "${STDIN_JSON:-}" ]; then
|
|
100
|
+
SOURCE=""
|
|
101
|
+
if command -v jq >/dev/null 2>&1; then
|
|
102
|
+
SOURCE=$(printf '%s' "$STDIN_JSON" | jq -r '.source // empty' 2>/dev/null || true)
|
|
103
|
+
else
|
|
104
|
+
SOURCE=$(printf '%s' "$STDIN_JSON" \
|
|
105
|
+
| grep -o '"source"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
|
106
|
+
| head -1 \
|
|
107
|
+
| sed 's/.*"source"[[:space:]]*:[[:space:]]*"//;s/"$//' 2>/dev/null || true)
|
|
108
|
+
fi
|
|
109
|
+
if [ -n "$SOURCE" ] && [ "$SOURCE" != "compact" ]; then
|
|
110
|
+
exit 0
|
|
111
|
+
fi
|
|
112
|
+
fi
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Static recovery/orientation block. ALWAYS emitted on a compaction start,
|
|
117
|
+
# for EVERY agent — this is the load-bearing default. Plain stdout from a
|
|
118
|
+
# SessionStart hook IS added to the model's context by Claude Code, so this
|
|
119
|
+
# re-orients the model the instant the native summary replaces the transcript.
|
|
120
|
+
# Deterministic heredoc string: no network, no CLI fork.
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
cat <<'EOF'
|
|
123
|
+
<compact-recovery source="switchroom working-state-reload hook">
|
|
124
|
+
Your context was just COMPACTED mid-conversation. This is NOT a fresh start:
|
|
125
|
+
you are CONTINUING a conversation the user experiences as unbroken. The native
|
|
126
|
+
summary above is lossy — it flattens or drops fast-moving detail (in-flight
|
|
127
|
+
IDs, the exact "where was I", recent phrasing). Do not greet the user or act
|
|
128
|
+
as if starting over; pick up where the conversation left off.
|
|
129
|
+
|
|
130
|
+
Re-orient using the recovery tools in THIS environment before continuing:
|
|
131
|
+
- Telegram chat history: the get_recent_messages MCP tool
|
|
132
|
+
(mcp__switchroom-telegram__get_recent_messages) to re-read what was just
|
|
133
|
+
being discussed.
|
|
134
|
+
- Hindsight memory: recall / reflect (mcp__hindsight__recall,
|
|
135
|
+
mcp__hindsight__reflect) for facts and decisions from earlier sessions.
|
|
136
|
+
- Workspace files for durable task state.
|
|
137
|
+
</compact-recovery>
|
|
138
|
+
EOF
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Resolve the working-state file. Primary: $TELEGRAM_STATE_DIR (exported by
|
|
142
|
+
# start.sh for telegram-plugin agents). Fallback: derive the conventional
|
|
143
|
+
# telegram state dir from the agent name, so the hook still works if invoked
|
|
144
|
+
# in a context where TELEGRAM_STATE_DIR is not exported. If neither resolves,
|
|
145
|
+
# skip the working-state append — the recovery block above already emitted,
|
|
146
|
+
# and the lean briefing below still runs.
|
|
147
|
+
#
|
|
148
|
+
# NOTE: this is a GUARDED block (not an early `exit`), because the lean
|
|
149
|
+
# post-compaction briefing further down must run for EVERY compaction,
|
|
150
|
+
# including the common case of an agent that keeps no working-state file.
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
STATE_DIR="${TELEGRAM_STATE_DIR:-}"
|
|
153
|
+
if [ -z "$STATE_DIR" ]; then
|
|
154
|
+
AGENT_NAME="${SWITCHROOM_AGENT_NAME:-}"
|
|
155
|
+
if [ -n "$AGENT_NAME" ] && [ -n "${HOME:-}" ]; then
|
|
156
|
+
STATE_DIR="$HOME/.switchroom/agents/$AGENT_NAME/telegram"
|
|
157
|
+
fi
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
WORKING_STATE_FILE=""
|
|
161
|
+
if [ -n "$STATE_DIR" ]; then
|
|
162
|
+
WORKING_STATE_FILE="$STATE_DIR/.working-state.md"
|
|
163
|
+
fi
|
|
164
|
+
|
|
165
|
+
# Append the working state only when the file resolves AND is non-empty.
|
|
166
|
+
if [ -n "$WORKING_STATE_FILE" ] && [ -s "$WORKING_STATE_FILE" ]; then
|
|
167
|
+
# -------------------------------------------------------------------------
|
|
168
|
+
# Resolve the working-state file's last-modified time so a stale, forgotten
|
|
169
|
+
# file is VISIBLY stale to the model rather than silently steering it. Try a
|
|
170
|
+
# portable sequence: GNU/busybox `stat -c %y`, then BSD/macOS `stat -f %Sm`,
|
|
171
|
+
# then GNU `date -r <file>`. If none work, omit the mtime — never fail the
|
|
172
|
+
# hook over it.
|
|
173
|
+
# -------------------------------------------------------------------------
|
|
174
|
+
MTIME=""
|
|
175
|
+
if MTIME=$(stat -c %y "$WORKING_STATE_FILE" 2>/dev/null) && [ -n "$MTIME" ]; then
|
|
176
|
+
:
|
|
177
|
+
elif MTIME=$(stat -f '%Sm' "$WORKING_STATE_FILE" 2>/dev/null) && [ -n "$MTIME" ]; then
|
|
178
|
+
:
|
|
179
|
+
elif MTIME=$(date -r "$WORKING_STATE_FILE" 2>/dev/null) && [ -n "$MTIME" ]; then
|
|
180
|
+
:
|
|
181
|
+
else
|
|
182
|
+
MTIME=""
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
# -------------------------------------------------------------------------
|
|
186
|
+
# Append the working state verbatim, wrapped in its own delimiter block. The
|
|
187
|
+
# header line carries the mtime (when resolvable) so a stale file reads as
|
|
188
|
+
# stale.
|
|
189
|
+
# -------------------------------------------------------------------------
|
|
190
|
+
printf '%s\n' '<working-state source="switchroom working-state-reload hook">'
|
|
191
|
+
if [ -n "$MTIME" ]; then
|
|
192
|
+
printf '%s\n' 'The following is your working-state file ('"$WORKING_STATE_FILE"', last updated '"$MTIME"'),'
|
|
193
|
+
else
|
|
194
|
+
printf '%s\n' 'The following is your working-state file ('"$WORKING_STATE_FILE"'),'
|
|
195
|
+
fi
|
|
196
|
+
printf '%s\n' 'reloaded verbatim so in-flight task state survives the summarizer. It may'
|
|
197
|
+
printf '%s\n' 'be stale — reconcile it against the summary and the recovery tools above'
|
|
198
|
+
printf '%s\n' 'before trusting it, then continue.'
|
|
199
|
+
printf '%s\n' '---'
|
|
200
|
+
cat "$WORKING_STATE_FILE"
|
|
201
|
+
printf '\n%s\n' '</working-state>'
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Lean post-compaction briefing (P1). ADDITIVE on source=compact, after the
|
|
206
|
+
# static recovery block and the optional working-state append. It re-seats the
|
|
207
|
+
# compacted session to fresh-boot PARITY: a scoped recent Telegram tail + a
|
|
208
|
+
# Hindsight recall, so the agent picks up the actual conversation rather than
|
|
209
|
+
# only being TOLD it was compacted.
|
|
210
|
+
#
|
|
211
|
+
# DRY: the assembly is delegated to handoff-briefing.sh --lean — the SINGLE
|
|
212
|
+
# briefing assembler. No copy of the sqlite/recall logic lives here. Lean mode
|
|
213
|
+
# emits ONLY the Telegram-tail + recall (daily-memory and the boot header are
|
|
214
|
+
# skipped — token duplication, the native summary already keeps recent turns),
|
|
215
|
+
# and IGNORES SWITCHROOM_PENDING_* to derive the db-latest surface (a stale
|
|
216
|
+
# pending-turn scope would brief the wrong chat at a mid-session compaction).
|
|
217
|
+
#
|
|
218
|
+
# SHARED PATH: this hook fires on SessionStart(compact) for EVERY agent,
|
|
219
|
+
# regardless of session_continuity.briefing mode (legacy vs gateway), so both
|
|
220
|
+
# modes get identical compaction re-seat through this one path.
|
|
221
|
+
#
|
|
222
|
+
# GRACEFUL: if the assembler script is not found, or emits nothing (no
|
|
223
|
+
# history.db, Hindsight unreachable), the block is simply omitted. The lean
|
|
224
|
+
# briefing NEVER fails the hook — the recovery block above already stands on
|
|
225
|
+
# its own. handoff-briefing.sh caps its only network hop (Hindsight) at a few
|
|
226
|
+
# seconds, so runtime is bounded.
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
BRIEFING_SCRIPT=""
|
|
229
|
+
_HOOK_DIR=$(dirname -- "$0" 2>/dev/null || true)
|
|
230
|
+
if [ -n "$_HOOK_DIR" ] && [ -r "$_HOOK_DIR/handoff-briefing.sh" ]; then
|
|
231
|
+
BRIEFING_SCRIPT="$_HOOK_DIR/handoff-briefing.sh"
|
|
232
|
+
elif command -v handoff-briefing.sh >/dev/null 2>&1; then
|
|
233
|
+
BRIEFING_SCRIPT="handoff-briefing.sh"
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
if [ -n "$BRIEFING_SCRIPT" ]; then
|
|
237
|
+
# Inner timeout, SHORTER than the 8s Claude Code hook budget, so a slow
|
|
238
|
+
# assembler degrades to "recovery block only" instead of losing everything.
|
|
239
|
+
# Without it, if the assembler runs long (e.g. an operator raises
|
|
240
|
+
# HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT past the hook budget) Claude Code kills
|
|
241
|
+
# the WHOLE hook at 8s and discards ALL stdout — including the near-unkillable
|
|
242
|
+
# <compact-recovery> orientation block already printed above. Capping the
|
|
243
|
+
# assembler at 5s keeps the #4390 recovery floor intact. `timeout` is
|
|
244
|
+
# coreutils (present in the agent image); fall back to an un-timed call if it
|
|
245
|
+
# is somehow unavailable, so the lean briefing still works.
|
|
246
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
247
|
+
LEAN_BRIEFING=$(timeout 5 bash "$BRIEFING_SCRIPT" --lean 2>/dev/null || true)
|
|
248
|
+
else
|
|
249
|
+
LEAN_BRIEFING=$(bash "$BRIEFING_SCRIPT" --lean 2>/dev/null || true)
|
|
250
|
+
fi
|
|
251
|
+
if [ -n "$LEAN_BRIEFING" ]; then
|
|
252
|
+
printf '%s\n' '<compact-briefing source="switchroom working-state-reload hook">'
|
|
253
|
+
printf '%s\n' 'The recent conversation and recalled memory below are re-seated so this'
|
|
254
|
+
printf '%s\n' 'compacted session has the same footing as a fresh boot. Use them to pick up'
|
|
255
|
+
printf '%s\n' 'the thread; reconcile against the native summary above before trusting either.'
|
|
256
|
+
printf '%s\n' '---'
|
|
257
|
+
printf '%s\n' "$LEAN_BRIEFING"
|
|
258
|
+
printf '%s\n' '</compact-briefing>'
|
|
259
|
+
fi
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
exit 0
|
|
@@ -11666,8 +11666,10 @@ var AgentSchema = exports_external.object({
|
|
|
11666
11666
|
bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
|
|
11667
11667
|
timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
|
|
11668
11668
|
auth: exports_external.object({
|
|
11669
|
-
override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
|
|
11670
|
-
|
|
11669
|
+
override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5."),
|
|
11670
|
+
strict: exports_external.boolean().optional().describe("Requires `override`. When true, the pin is a hard binding, not a " + "routing preference: the broker NEVER serves this agent from any " + "other account — no failover to `auth.fallback_order` while the " + "pinned account is quota-walled or exhausted. The agent rides out " + "the wall on its own account (surfacing the normal 429/quota " + "cards) instead of silently borrowing fleet quota. Use for " + "accounts that must never cross a billing/compliance boundary " + "(e.g. an employer-provided subscription)."),
|
|
11671
|
+
exclusive: exports_external.boolean().optional().describe("Requires `override`. When true, the pinned account belongs to " + "THIS agent alone: the broker refuses to serve it to any other " + "agent or consumer, refuses `auth use <label>` (set-active) onto " + "it, and refuses pinning another agent to it. Config that routes " + "others to the account (`auth.active`, `auth.fallback_order`, " + "another agent's `override`, a consumer pin) is rejected at load. " + "Usually paired with `strict: true` for full two-way isolation.")
|
|
11672
|
+
}).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases, " + "with optional `strict:` (never borrow another account) and " + "`exclusive:` (no one else may use the pinned account) hardening. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
|
|
11671
11673
|
dm_only: exports_external.boolean().optional().describe("Mark this agent as a DM-only bot — has its own bot_token and lives " + "exclusively in a private chat with the operator. Suppresses " + "scaffolding's default behavior of inheriting the global " + "telegram.forum_chat_id into the agent's access.json `groups` entry " + "(the forum chat the bot isn't a member of, which would otherwise " + "trigger a 'boot-probe-failed: 400 chat not found' warning every " + "restart). topic_name is still schema-required but unused — set it " + "to a display label like 'DM' for /switchroom status output."),
|
|
11672
11674
|
topic_name: exports_external.string().describe("Telegram forum topic display name"),
|
|
11673
11675
|
topic_emoji: exports_external.string().optional().describe("Emoji for the topic (e.g., '\uD83C\uDFCB️')"),
|
|
@@ -11742,6 +11744,17 @@ var AgentSchema = exports_external.object({
|
|
|
11742
11744
|
tmp_size: exports_external.string().regex(/^\d+(\.\d+)?[kmgKMG]?$/, "tmp_size must be a Docker size string like '1g', '4g', '512m'").refine((v) => parseFloat(v) > 0, "tmp_size must be greater than zero").optional()
|
|
11743
11745
|
}).optional()
|
|
11744
11746
|
}).superRefine((agent, ctx) => {
|
|
11747
|
+
if (agent.auth && !agent.auth.override) {
|
|
11748
|
+
for (const flag of ["strict", "exclusive"]) {
|
|
11749
|
+
if (agent.auth[flag]) {
|
|
11750
|
+
ctx.addIssue({
|
|
11751
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11752
|
+
message: `\`auth.${flag}: true\` requires \`auth.override: <account>\` — there is no pinned account for it to apply to.`,
|
|
11753
|
+
path: ["auth", flag]
|
|
11754
|
+
});
|
|
11755
|
+
}
|
|
11756
|
+
}
|
|
11757
|
+
}
|
|
11745
11758
|
if (agent.dm_only !== true)
|
|
11746
11759
|
return;
|
|
11747
11760
|
const tg = agent.channels?.telegram;
|
|
@@ -11854,6 +11867,13 @@ var UserSchema = exports_external.object({
|
|
|
11854
11867
|
profile_bank: exports_external.string().describe("Hindsight bank holding this user's memory profile (author via " + "`switchroom memory profile add <bank> ...`)."),
|
|
11855
11868
|
person_id: exports_external.string().optional().describe("Free-text display name projected into the inbound `<channel>` " + 'tag\'s `user` attribute (e.g. "Lisa") so an agent can greet by ' + "name instead of only seeing the raw Telegram id/username. NOT a " + "stable identity system — just a label. Resolution is boot-time-only " + "(no hot-reload; a config change needs an agent restart) and " + "chat-scoped (a resolved name is only shown in a chat/group the " + "person is actually a member of, per that chat's access.json " + "membership — never broadcast into every chat the agent operates " + "in). Keep this broadly safe to show, the same discipline as " + "picking a `profile_bank` name: there is no automated enforcement " + "that a `person_id` stays safe if a group's membership changes " + "later — see docs/configuration.md.")
|
|
11856
11869
|
});
|
|
11870
|
+
var ConfigRepoConfigSchema = exports_external.object({
|
|
11871
|
+
enabled: exports_external.boolean().default(false).describe("Master switch for config-repo change control. Default false (opt-in). " + "When false the `config-repo sync` verb still runs on demand but the " + "doctor check and (later) the scheduled tick treat the feature as off."),
|
|
11872
|
+
path: exports_external.string().regex(/^[a-zA-Z0-9~._\-/]+$/, "config_repo.path must not contain shell-special characters ($, `, \", ', \\, etc.)").default("~/.switchroom-config").describe("Filesystem path to the operator's private config repo. Must be a git " + "repo (else `switchroom doctor` FAILs). Tilde-expanded at read time."),
|
|
11873
|
+
push: exports_external.boolean().default(true).describe("When true, `config-repo sync` pushes after committing (subject to the " + "`require_private` gate). When false, it commits locally only — useful " + "for offline hosts or a review-before-push workflow."),
|
|
11874
|
+
remote: exports_external.string().min(1).default("origin").describe("Git remote name to push to. Default `origin`."),
|
|
11875
|
+
require_private: exports_external.boolean().default(true).describe("Refuse to PUSH unless the GitHub API confirms the remote repo is " + "private. On a public remote — or when the API is unreachable — the " + "push is skipped (commits still land locally) and a WARN is emitted. " + "Fail-safe against exfiltrating memory files / workspace state to a " + "repo that has been flipped public. Default true.")
|
|
11876
|
+
});
|
|
11857
11877
|
var SwitchroomConfigSchema = exports_external.object({
|
|
11858
11878
|
switchroom: exports_external.object({
|
|
11859
11879
|
version: exports_external.literal(1).describe("Config schema version"),
|
|
@@ -11866,6 +11886,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
11866
11886
|
memory: MemoryBackendConfigSchema.optional(),
|
|
11867
11887
|
hindsight: HindsightConfigSchema.optional().describe("Fleet-singleton Hindsight (memory backend) configuration. Currently " + "just the LLM knob (provider + model) used for retain/reflect/" + "consolidation. Both fields optional; when unset the container falls " + "back to provider=claude-code + the hard-coded HINDSIGHT_DEFAULT_MODEL " + "so nothing changes for operators who don't set it. Read at container " + "launch by startHindsight() (src/setup/hindsight.ts) — takes effect on " + "the next `switchroom apply` / `memory setup --recreate`."),
|
|
11868
11888
|
vault: VaultConfigSchema.optional(),
|
|
11889
|
+
config_repo: ConfigRepoConfigSchema.optional().describe("Change control for the operator's private ~/.switchroom-config git repo " + "(backup + audit trail of live host config, agent workspace state, and " + "mirrored personal skills). Consumed by `switchroom config-repo sync` and " + "the `config repo` doctor check. Optional and OFF by default."),
|
|
11869
11890
|
auth: exports_external.object({
|
|
11870
11891
|
active: exports_external.string().min(1).optional().describe("Fleet-wide active Anthropic account label. Every agent without " + "an explicit `agent.auth.override` uses this account. See " + "docs/auth.md for the full model. Set by `switchroom auth use <label>`."),
|
|
11871
11892
|
fallback_order: exports_external.array(exports_external.string().min(1)).optional().describe("Ordered list of account labels for `switchroom auth rotate` to cycle " + "through when the active account hits a quota event. First entry is " + "normally the same as `auth.active`. When unset, `rotate` is a no-op."),
|
|
@@ -11933,6 +11954,48 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
11933
11954
|
for (const [name, a] of Object.entries(cfg.agents ?? {})) {
|
|
11934
11955
|
checkServes(a.serves, ["agents", name, "serves"]);
|
|
11935
11956
|
}
|
|
11957
|
+
for (const [name, a] of Object.entries(cfg.agents ?? {})) {
|
|
11958
|
+
const agentAuth = a.auth;
|
|
11959
|
+
if (!agentAuth?.exclusive || !agentAuth.override)
|
|
11960
|
+
continue;
|
|
11961
|
+
const acct = agentAuth.override;
|
|
11962
|
+
if (cfg.auth?.active === acct) {
|
|
11963
|
+
ctx.addIssue({
|
|
11964
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11965
|
+
message: `account '${acct}' is exclusive to agent '${name}' ` + `(agents.${name}.auth.exclusive) — it cannot be the fleet \`auth.active\``,
|
|
11966
|
+
path: ["auth", "active"]
|
|
11967
|
+
});
|
|
11968
|
+
}
|
|
11969
|
+
const fbIdx = (cfg.auth?.fallback_order ?? []).indexOf(acct);
|
|
11970
|
+
if (fbIdx !== -1) {
|
|
11971
|
+
ctx.addIssue({
|
|
11972
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11973
|
+
message: `account '${acct}' is exclusive to agent '${name}' ` + `(agents.${name}.auth.exclusive) — it cannot appear in \`auth.fallback_order\``,
|
|
11974
|
+
path: ["auth", "fallback_order", fbIdx]
|
|
11975
|
+
});
|
|
11976
|
+
}
|
|
11977
|
+
for (const [other, oa] of Object.entries(cfg.agents ?? {})) {
|
|
11978
|
+
if (other === name)
|
|
11979
|
+
continue;
|
|
11980
|
+
const otherOverride = oa.auth?.override;
|
|
11981
|
+
if (otherOverride === acct) {
|
|
11982
|
+
ctx.addIssue({
|
|
11983
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11984
|
+
message: `account '${acct}' is exclusive to agent '${name}' ` + `(agents.${name}.auth.exclusive) — agent '${other}' cannot pin it`,
|
|
11985
|
+
path: ["agents", other, "auth", "override"]
|
|
11986
|
+
});
|
|
11987
|
+
}
|
|
11988
|
+
}
|
|
11989
|
+
(cfg.auth?.consumers ?? []).forEach((c, i) => {
|
|
11990
|
+
if (c.account === acct) {
|
|
11991
|
+
ctx.addIssue({
|
|
11992
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11993
|
+
message: `account '${acct}' is exclusive to agent '${name}' ` + `(agents.${name}.auth.exclusive) — consumer '${c.name}' cannot pin it`,
|
|
11994
|
+
path: ["auth", "consumers", i, "account"]
|
|
11995
|
+
});
|
|
11996
|
+
}
|
|
11997
|
+
});
|
|
11998
|
+
}
|
|
11936
11999
|
const microsoftAccounts = cfg.microsoft_accounts;
|
|
11937
12000
|
for (const [name, a] of Object.entries(cfg.agents ?? {})) {
|
|
11938
12001
|
const mw = a.microsoft_workspace;
|