switchroom 0.19.16 → 0.19.18
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/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +11 -3
- package/dist/auth-broker/index.js +29 -4
- package/dist/cli/notion-write-pretool.mjs +11 -3
- package/dist/cli/switchroom.js +8307 -7620
- package/dist/host-control/main.js +626 -36
- package/dist/vault/approvals/kernel-server.js +30 -5
- package/dist/vault/broker/server.js +71 -18
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +8 -4
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +11 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +15 -20
- package/telegram-plugin/dist/gateway/gateway.js +763 -373
- package/telegram-plugin/dist/server.js +19 -20
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- package/vendor/hindsight-memory/settings.json +1 -1
package/bin/run-hook.sh
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
# claude on stdout still work.
|
|
17
17
|
# - Stderr is teed: visible in journald as before AND captured to a
|
|
18
18
|
# buffer so we can attach the tail to the issue detail.
|
|
19
|
+
# - On EVERY invocation, success or failure: append one JSON line
|
|
20
|
+
# carrying `duration_ms` to the hook-timing log (#3564).
|
|
19
21
|
# - On exit 0: auto-resolve any prior unresolved issue with the same
|
|
20
22
|
# fingerprint. So a transient flake clears itself on next success.
|
|
21
23
|
# - On non-zero exit: record an issue with severity=error, code = the
|
|
@@ -27,6 +29,45 @@
|
|
|
27
29
|
#
|
|
28
30
|
# The wrapper exits with the original command's exit code, so claude
|
|
29
31
|
# code's hook contract (block / allow / non-zero behaviour) is preserved.
|
|
32
|
+
#
|
|
33
|
+
# ── Timing log (#3564) ──────────────────────────────────────────────────
|
|
34
|
+
#
|
|
35
|
+
# Before this, the wrapper logged ONLY failures — no timestamps, no
|
|
36
|
+
# durations. That is why `secret-guard-pretool` could sit at ~145ms per
|
|
37
|
+
# tool call for months (#3543) and `workspace-dynamic-hook` at ~825ms-1.1s
|
|
38
|
+
# per message (#3546) without producing a single log line: neither was
|
|
39
|
+
# FAILING, merely slow, and slow-but-successful was invisible.
|
|
40
|
+
#
|
|
41
|
+
# So every invocation now appends one JSON line to
|
|
42
|
+
# ${TELEGRAM_STATE_DIR}/hook-timings-<Ddd>.log (Mon..Sun)
|
|
43
|
+
# shaped as:
|
|
44
|
+
# {"ts":"...","date":"YYYY-MM-DD","source":"hook:x","code":"x.mjs",
|
|
45
|
+
# "duration_ms":142,"status":0}
|
|
46
|
+
# so `grep duration_ms` answers any future hook-cost question.
|
|
47
|
+
#
|
|
48
|
+
# Cost discipline — the wrapper's own overhead was measured at 0-2ms and
|
|
49
|
+
# must stay there, so the whole timing path is FORK-FREE:
|
|
50
|
+
# - `EPOCHREALTIME` (bash 5 builtin) for start/end, integer arithmetic
|
|
51
|
+
# for the delta. No `date`, no `stat`, no subshell.
|
|
52
|
+
# - The file name embeds the weekday, giving a self-truncating 7-day
|
|
53
|
+
# ring: on the first write of a new day the existing file's first line
|
|
54
|
+
# carries last week's date, so we truncate instead of appending. The
|
|
55
|
+
# staleness check is a builtin `read` from the file — measured
|
|
56
|
+
# 0.024ms/append vs 0.81ms for a single `stat` fork, which is why
|
|
57
|
+
# size-based rotation was rejected here.
|
|
58
|
+
# Measured net cost of `log_timing` (2000-iteration in-shell loop, a busy
|
|
59
|
+
# build host): 0.40-0.78ms per invocation, dominated by the append's
|
|
60
|
+
# open/write/close. That fits inside the stated 0-2ms wrapper budget with
|
|
61
|
+
# room to spare; a `date` + `stat` implementation would not.
|
|
62
|
+
#
|
|
63
|
+
# Env:
|
|
64
|
+
# SWITCHROOM_HOOK_TIMING=0 disable timing log entirely
|
|
65
|
+
# SWITCHROOM_HOOK_TIMING_DIR override the log directory
|
|
66
|
+
# SWITCHROOM_HOOK_TIMING_MIN_MS only log invocations at/above this
|
|
67
|
+
# duration (default 0 = log everything;
|
|
68
|
+
# the whole failure mode here is that
|
|
69
|
+
# slow-but-successful is invisible, so
|
|
70
|
+
# the default must not filter).
|
|
30
71
|
|
|
31
72
|
set -u
|
|
32
73
|
|
|
@@ -82,9 +123,15 @@ STATE_DIR="${TELEGRAM_STATE_DIR:-}"
|
|
|
82
123
|
STDERR_TMP="$(mktemp -t run-hook-stderr.XXXXXX 2>/dev/null || mktemp)"
|
|
83
124
|
trap 'rm -f "$STDERR_TMP" 2>/dev/null || true' EXIT
|
|
84
125
|
|
|
126
|
+
# Fork-free monotonic-ish start stamp. EPOCHREALTIME is a bash 5 builtin;
|
|
127
|
+
# on bash 4 it is unset and timing degrades to a silent no-op.
|
|
128
|
+
TIMING_START="${EPOCHREALTIME:-}"
|
|
129
|
+
|
|
85
130
|
"$COMMAND" "$@" 2>"$STDERR_TMP"
|
|
86
131
|
STATUS=$?
|
|
87
132
|
|
|
133
|
+
TIMING_END="${EPOCHREALTIME:-}"
|
|
134
|
+
|
|
88
135
|
# Replay captured stderr to our own stderr now that the command has
|
|
89
136
|
# fully exited — preserves journald visibility without the streaming
|
|
90
137
|
# property (which hooks don't need; they're short-lived).
|
|
@@ -96,6 +143,102 @@ emit_warn() {
|
|
|
96
143
|
echo "run-hook.sh: $1" >&2
|
|
97
144
|
}
|
|
98
145
|
|
|
146
|
+
# ── Timing log (#3564) ────────────────────────────────────────────────────
|
|
147
|
+
#
|
|
148
|
+
# Everything below is deliberately FORK-FREE: no command substitution (which
|
|
149
|
+
# forks a subshell), no `date`, no `stat`. Helpers return values by assigning
|
|
150
|
+
# to a caller-visible global rather than via `$(...)`.
|
|
151
|
+
|
|
152
|
+
# Split EPOCHREALTIME ("1753412345.123456") into whole milliseconds.
|
|
153
|
+
# LC_NUMERIC can make the separator a comma, so accept either.
|
|
154
|
+
# Sets `_EPOCH_MS`; returns 1 (and leaves _EPOCH_MS empty) on a bad shape.
|
|
155
|
+
_EPOCH_MS=""
|
|
156
|
+
_epoch_to_ms() {
|
|
157
|
+
local raw="$1" secs usecs
|
|
158
|
+
_EPOCH_MS=""
|
|
159
|
+
case "$raw" in
|
|
160
|
+
*[.,]*) secs="${raw%%[.,]*}"; usecs="${raw#*[.,]}" ;;
|
|
161
|
+
*) secs="$raw"; usecs="0" ;;
|
|
162
|
+
esac
|
|
163
|
+
case "$secs" in
|
|
164
|
+
''|*[!0-9]*) return 1 ;;
|
|
165
|
+
esac
|
|
166
|
+
case "$usecs" in
|
|
167
|
+
''|*[!0-9]*) return 1 ;;
|
|
168
|
+
esac
|
|
169
|
+
# Pad/truncate the fractional part to exactly 6 digits, then force base 10
|
|
170
|
+
# so a leading zero is not read as octal.
|
|
171
|
+
usecs="${usecs}000000"
|
|
172
|
+
usecs="${usecs:0:6}"
|
|
173
|
+
_EPOCH_MS=$(( secs * 1000 + (10#$usecs) / 1000 ))
|
|
174
|
+
return 0
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# JSON-escape into `_JSON_ESCAPED`. Hook sources and command basenames are
|
|
178
|
+
# operator-authored and tame, but a backslash or quote in one must not be
|
|
179
|
+
# able to emit a malformed line (or inject extra fields) into a log that
|
|
180
|
+
# other tooling parses.
|
|
181
|
+
_JSON_ESCAPED=""
|
|
182
|
+
_json_escape() {
|
|
183
|
+
local s="$1"
|
|
184
|
+
s="${s//\\/\\\\}"
|
|
185
|
+
s="${s//\"/\\\"}"
|
|
186
|
+
_JSON_ESCAPED="$s"
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
log_timing() {
|
|
190
|
+
[ "${SWITCHROOM_HOOK_TIMING:-1}" = "0" ] && return 0
|
|
191
|
+
[ -n "$TIMING_START" ] || return 0
|
|
192
|
+
[ -n "$TIMING_END" ] || return 0
|
|
193
|
+
|
|
194
|
+
local timing_dir="${SWITCHROOM_HOOK_TIMING_DIR:-$STATE_DIR}"
|
|
195
|
+
[ -n "$timing_dir" ] || return 0
|
|
196
|
+
[ -d "$timing_dir" ] || return 0
|
|
197
|
+
|
|
198
|
+
local start_ms end_ms duration_ms
|
|
199
|
+
_epoch_to_ms "$TIMING_START" || return 0
|
|
200
|
+
start_ms="$_EPOCH_MS"
|
|
201
|
+
_epoch_to_ms "$TIMING_END" || return 0
|
|
202
|
+
end_ms="$_EPOCH_MS"
|
|
203
|
+
duration_ms=$(( end_ms - start_ms ))
|
|
204
|
+
# A clock step backwards must not emit a negative duration.
|
|
205
|
+
[ "$duration_ms" -lt 0 ] && duration_ms=0
|
|
206
|
+
|
|
207
|
+
local min_ms="${SWITCHROOM_HOOK_TIMING_MIN_MS:-0}"
|
|
208
|
+
case "$min_ms" in
|
|
209
|
+
''|*[!0-9]*) min_ms=0 ;;
|
|
210
|
+
esac
|
|
211
|
+
[ "$duration_ms" -lt "$min_ms" ] && return 0
|
|
212
|
+
|
|
213
|
+
# Builtin date formatting — no `date` fork.
|
|
214
|
+
local today dow ts
|
|
215
|
+
printf -v today '%(%Y-%m-%d)T' -1
|
|
216
|
+
printf -v dow '%(%a)T' -1
|
|
217
|
+
printf -v ts '%(%Y-%m-%dT%H:%M:%S%z)T' -1
|
|
218
|
+
|
|
219
|
+
local logfile="${timing_dir}/hook-timings-${dow}.log"
|
|
220
|
+
|
|
221
|
+
# 7-day self-truncating ring: the weekday-named file is either today's or
|
|
222
|
+
# exactly a week stale, so if its first line does not carry today's date,
|
|
223
|
+
# reset it. `read` from a file is a builtin — no fork.
|
|
224
|
+
if [ -s "$logfile" ]; then
|
|
225
|
+
local first=''
|
|
226
|
+
read -r first < "$logfile" 2>/dev/null || first=''
|
|
227
|
+
case "$first" in
|
|
228
|
+
*"\"date\":\"${today}\""*) : ;;
|
|
229
|
+
*) : > "$logfile" 2>/dev/null || return 0 ;;
|
|
230
|
+
esac
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
local esc_source esc_code
|
|
234
|
+
_json_escape "$SOURCE"; esc_source="$_JSON_ESCAPED"
|
|
235
|
+
_json_escape "$CODE"; esc_code="$_JSON_ESCAPED"
|
|
236
|
+
|
|
237
|
+
printf '{"ts":"%s","date":"%s","source":"%s","code":"%s","duration_ms":%s,"status":%s}\n' \
|
|
238
|
+
"$ts" "$today" "$esc_source" "$esc_code" "$duration_ms" "$STATUS" \
|
|
239
|
+
>> "$logfile" 2>/dev/null || true
|
|
240
|
+
}
|
|
241
|
+
|
|
99
242
|
# When RUN_HOOK_DEBUG=1 is set, drop the stderr redirect on the
|
|
100
243
|
# issues-CLI invocations so an operator debugging a broken record-path
|
|
101
244
|
# sees the actual cause in journald instead of just the generic
|
|
@@ -228,6 +371,11 @@ resolve_success() {
|
|
|
228
371
|
fi
|
|
229
372
|
}
|
|
230
373
|
|
|
374
|
+
# Timing is emitted on EVERY path, including the degraded no-CLI path below
|
|
375
|
+
# — it writes a file directly and does not need the switchroom CLI, so hook
|
|
376
|
+
# cost stays observable even when issue tracking is off (#3564).
|
|
377
|
+
log_timing
|
|
378
|
+
|
|
231
379
|
if [ -z "$SWITCHROOM_CLI" ]; then
|
|
232
380
|
# Degraded path. Emit a single warning so the operator knows visibility
|
|
233
381
|
# is off, but don't change the hook's exit semantics.
|
|
@@ -66,24 +66,48 @@ mkdir -p "$CACHE_DIR" 2>/dev/null || true
|
|
|
66
66
|
# Date-keyed cache filename: when the calendar day rolls over, the
|
|
67
67
|
# `today's daily` file path the renderer reads changes (the template
|
|
68
68
|
# embeds different filenames in its output), so we invalidate the cache
|
|
69
|
-
# at midnight
|
|
70
|
-
#
|
|
71
|
-
|
|
69
|
+
# at midnight by varying the filename.
|
|
70
|
+
#
|
|
71
|
+
# Prior days' files are never cleaned up — there is no sweeper for this
|
|
72
|
+
# directory anywhere in the repo. That is deliberate and cheap, not an
|
|
73
|
+
# oversight: three small files per agent per day (~1100/year), a hash
|
|
74
|
+
# and a signature of 65 bytes each plus a body bounded by
|
|
75
|
+
# DEFAULT_DYNAMIC_TOTAL_MAX_CHARS. If that ever needs reclaiming it
|
|
76
|
+
# should be a real sweeper, not a comment claiming one exists.
|
|
77
|
+
#
|
|
78
|
+
# LOCAL time, not UTC — this MUST match the renderer. See
|
|
79
|
+
# `dailyMemoryRelativePath` in src/agents/workspace.ts, which derives
|
|
80
|
+
# today's daily-memory path from getFullYear/getMonth/getDate (local)
|
|
81
|
+
# on purpose: UTC would roll "today" over at early-morning for UTC+10
|
|
82
|
+
# hosts. When the hook used `date -u` here, every day from 00:00 until
|
|
83
|
+
# UTC midnight local the renderer's "today" file was absent from the
|
|
84
|
+
# hook's watch set, so writes to it did not invalidate the cache and a
|
|
85
|
+
# stale body was served.
|
|
86
|
+
CACHE_DATE="$(date +%Y-%m-%d)"
|
|
72
87
|
CACHE_FILE="$CACHE_DIR/workspace-dynamic.${CACHE_DATE}.hash"
|
|
73
88
|
BODY_FILE="$CACHE_DIR/workspace-dynamic.${CACHE_DATE}.body"
|
|
89
|
+
SIG_FILE="$CACHE_DIR/workspace-dynamic.${CACHE_DATE}.srcsig"
|
|
74
90
|
|
|
75
|
-
#
|
|
76
|
-
#
|
|
91
|
+
# Source-signature fast-skip: if BODY_FILE exists AND the signature of
|
|
92
|
+
# the workspace source *set* is byte-identical to the signature recorded
|
|
93
|
+
# when that body was produced, we can emit the cached body and skip the
|
|
77
94
|
# ~800ms `switchroom workspace render` invocation entirely. The renderer
|
|
78
95
|
# reads MEMORY.md, today's daily, yesterday's daily — see
|
|
79
96
|
# `loadDynamicBootstrapFiles` in src/agents/workspace.ts.
|
|
80
97
|
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
98
|
+
# The signature covers each source's name + mtime + size, and records
|
|
99
|
+
# missing files explicitly. A max-mtime comparison (the previous
|
|
100
|
+
# approach) could not see a *deletion*: a missing source contributed
|
|
101
|
+
# mtime 0, which never exceeded the body's mtime, so removing MEMORY.md
|
|
102
|
+
# served the cached body forever. It also lost any source modified
|
|
103
|
+
# between the render and the body write (the write-during-render race):
|
|
104
|
+
# such a source ended up older than the body and became invisible
|
|
105
|
+
# indefinitely. Capturing the signature *before* invoking the renderer
|
|
106
|
+
# closes both: anything that changes during or after the render window
|
|
107
|
+
# yields a different signature on the next turn.
|
|
108
|
+
#
|
|
109
|
+
# Forensics measured this fast-path saving ~825ms on the common case
|
|
110
|
+
# (chat turns where no source has changed since the last turn).
|
|
87
111
|
#
|
|
88
112
|
# Resolve the agent's workspace dir. Switchroom uses
|
|
89
113
|
# `~/.switchroom/agents/<name>/workspace/` by default. We avoid invoking
|
|
@@ -95,25 +119,63 @@ BODY_FILE="$CACHE_DIR/workspace-dynamic.${CACHE_DATE}.body"
|
|
|
95
119
|
AGENT_DIR="${SWITCHROOM_AGENT_DIR:-$HOME/.switchroom/agents/$AGENT_NAME}"
|
|
96
120
|
WS_DIR="$AGENT_DIR/workspace"
|
|
97
121
|
TODAY_FILE="$WS_DIR/memory/${CACHE_DATE}.md"
|
|
98
|
-
|
|
122
|
+
# Local-time "yesterday", matching addDays(now, -1) in workspace.ts.
|
|
123
|
+
YESTERDAY_DATE="$(date -d 'yesterday' +%Y-%m-%d 2>/dev/null || echo "")"
|
|
99
124
|
YESTERDAY_FILE="$WS_DIR/memory/${YESTERDAY_DATE}.md"
|
|
100
125
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
126
|
+
# Signature of the source set: name + mtime + size for each source the
|
|
127
|
+
# renderer reads, with missing files recorded explicitly so a deletion
|
|
128
|
+
# changes the signature.
|
|
129
|
+
#
|
|
130
|
+
# `%.9Y` (nanosecond mtime), NOT `%Y`. With whole-second mtimes, a
|
|
131
|
+
# content change that lands in the same second as the previous one and
|
|
132
|
+
# happens to keep the same file size produces an identical signature, so
|
|
133
|
+
# the edit is served stale indefinitely — until some later change moves
|
|
134
|
+
# the second or the size. That is strictly worse than the max-mtime
|
|
135
|
+
# comparison this replaced, which fell through on an equal-second
|
|
136
|
+
# collision (`-gt` is false when equal) and re-rendered. Nanosecond
|
|
137
|
+
# resolution closes it. If a filesystem reports a zero nanosecond field
|
|
138
|
+
# the signature degrades to second precision, i.e. exactly the old
|
|
139
|
+
# behaviour, never worse.
|
|
140
|
+
_ws_source_signature() {
|
|
141
|
+
local src meta
|
|
142
|
+
{
|
|
143
|
+
for src in "$WS_DIR/MEMORY.md" "$TODAY_FILE" "$YESTERDAY_FILE"; do
|
|
144
|
+
if [ -f "$src" ]; then
|
|
145
|
+
meta=$(stat -c '%.9Y:%s' "$src" 2>/dev/null || echo "stat-error")
|
|
146
|
+
printf '%s\t%s\n' "$src" "$meta"
|
|
147
|
+
else
|
|
148
|
+
printf '%s\tMISSING\n' "$src"
|
|
112
149
|
fi
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
150
|
+
done
|
|
151
|
+
} | sha256sum 2>/dev/null | cut -d' ' -f1
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Captured BEFORE the renderer runs, so a source mutated during the
|
|
155
|
+
# render window is not falsely credited to this body.
|
|
156
|
+
SRC_SIG="$(_ws_source_signature)"
|
|
157
|
+
|
|
158
|
+
# Is the cached body intact? CACHE_FILE holds sha256 of the render output
|
|
159
|
+
# with no trailing newline; BODY_FILE holds that same output plus one
|
|
160
|
+
# trailing newline (or is empty for an empty render). Command substitution
|
|
161
|
+
# strips trailing newlines, so this reproduces the recorded hash exactly in
|
|
162
|
+
# both cases. Without it a body truncated by a crash mid-write is served to
|
|
163
|
+
# the model verbatim and pinned into the fast path forever, because the
|
|
164
|
+
# signature only vouches for the *sources*, never for the body. One sha256
|
|
165
|
+
# over at most DEFAULT_DYNAMIC_TOTAL_MAX_CHARS is negligible against the
|
|
166
|
+
# ~825ms render it guards.
|
|
167
|
+
_ws_body_intact() {
|
|
168
|
+
local recorded actual
|
|
169
|
+
recorded=$(head -1 "$CACHE_FILE" 2>/dev/null || echo "")
|
|
170
|
+
[ -n "$recorded" ] || return 1
|
|
171
|
+
actual=$(printf '%s' "$(cat "$BODY_FILE" 2>/dev/null)" | sha256sum 2>/dev/null | cut -d' ' -f1)
|
|
172
|
+
[ -n "$actual" ] && [ "$actual" = "$recorded" ]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if [ -f "$BODY_FILE" ] && [ -f "$SIG_FILE" ] && [ -n "$SRC_SIG" ]; then
|
|
176
|
+
RECORDED_SIG=$(head -1 "$SIG_FILE" 2>/dev/null || echo "")
|
|
177
|
+
if [ "$RECORDED_SIG" = "$SRC_SIG" ] && _ws_body_intact; then
|
|
178
|
+
# Fast path: the source set is unchanged since this body was made.
|
|
117
179
|
# In inject-on-change mode, also check the session-state file — if the
|
|
118
180
|
# session_id matches the last-emitted session AND the hash matches, we
|
|
119
181
|
# can suppress entirely (model already has this content in context).
|
|
@@ -155,11 +217,25 @@ fi
|
|
|
155
217
|
# <50ms; 3s is generous headroom.
|
|
156
218
|
WS_DYNAMIC=$(timeout 3 switchroom workspace render "$AGENT_NAME" --dynamic --warning-mode off 2>/dev/null || true)
|
|
157
219
|
|
|
158
|
-
#
|
|
159
|
-
# empty body would re-emit empty
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
220
|
+
# An empty render IS cached. It used to `exit 0` here without writing the
|
|
221
|
+
# cache, on the theory that "caching an empty body would re-emit empty
|
|
222
|
+
# forever even after MEMORY comes back online" — that justification is
|
|
223
|
+
# obsolete: the fast-skip re-derives the source signature every turn, so a
|
|
224
|
+
# MEMORY.md that later appears (or any daily-memory write) changes the
|
|
225
|
+
# signature and forces a fresh render. Not caching it meant agents whose
|
|
226
|
+
# render is empty never had a cached body, the fast-skip never engaged, and
|
|
227
|
+
# they paid the full ~825ms-1.1s render on every single message to produce
|
|
228
|
+
# nothing.
|
|
229
|
+
#
|
|
230
|
+
# BODY_OUT is the exact byte string emitted on both the fresh and the cached
|
|
231
|
+
# path, so `cat "$BODY_FILE"` is byte-identical to a fresh emit (Anthropic's
|
|
232
|
+
# prompt cache is keyed on byte equality). Empty render → empty body file →
|
|
233
|
+
# empty stdout, not a bare newline.
|
|
234
|
+
if [ -n "$WS_DYNAMIC" ]; then
|
|
235
|
+
BODY_OUT="$WS_DYNAMIC
|
|
236
|
+
"
|
|
237
|
+
else
|
|
238
|
+
BODY_OUT=""
|
|
163
239
|
fi
|
|
164
240
|
|
|
165
241
|
# Content-addressed dedupe sidecar. Anthropic's prompt cache is keyed on
|
|
@@ -197,7 +273,41 @@ _ws_record_session_state() {
|
|
|
197
273
|
fi
|
|
198
274
|
}
|
|
199
275
|
|
|
276
|
+
# Write a cache file atomically: fill a per-PID temp file, then rename it
|
|
277
|
+
# into place. A plain `>` redirect truncates first, so a concurrent hook
|
|
278
|
+
# process can observe (and cache, and serve) a half-written body. rename(2)
|
|
279
|
+
# is atomic within a filesystem, so a reader sees either the whole old file
|
|
280
|
+
# or the whole new one.
|
|
281
|
+
_ws_write_atomic() {
|
|
282
|
+
local dest="$1" content="$2" tmp="$1.tmp.$$"
|
|
283
|
+
if printf '%s' "$content" > "$tmp" 2>/dev/null; then
|
|
284
|
+
mv -f "$tmp" "$dest" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true
|
|
285
|
+
else
|
|
286
|
+
rm -f "$tmp" 2>/dev/null || true
|
|
287
|
+
fi
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
# Publish the body and the pre-render source signature that vouches for it.
|
|
291
|
+
# Body first, signature second, both atomic: the signature is what arms the
|
|
292
|
+
# fast path, so it must never become visible before the body it describes.
|
|
293
|
+
# The signature is only written once the body file actually exists, so a
|
|
294
|
+
# failed body write can never leave a "fresh" signature pointing at a stale
|
|
295
|
+
# body.
|
|
296
|
+
_ws_publish() {
|
|
297
|
+
_ws_write_atomic "$BODY_FILE" "$BODY_OUT"
|
|
298
|
+
if [ -n "$SRC_SIG" ] && [ -f "$BODY_FILE" ]; then
|
|
299
|
+
_ws_write_atomic "$SIG_FILE" "$SRC_SIG
|
|
300
|
+
"
|
|
301
|
+
fi
|
|
302
|
+
}
|
|
303
|
+
|
|
200
304
|
if [ -n "$NEW_HASH" ] && [ "$NEW_HASH" = "$OLD_HASH" ] && [ -f "$BODY_FILE" ]; then
|
|
305
|
+
# Rewrite the body unconditionally, even though the hash says it is
|
|
306
|
+
# unchanged. NEW_HASH and OLD_HASH both derive from CACHE_FILE and the
|
|
307
|
+
# fresh render — neither validates BODY_FILE — so a body truncated by a
|
|
308
|
+
# crash mid-write would otherwise be cat'd to the model AND pinned into
|
|
309
|
+
# the fast path by the signature we are about to stamp on it.
|
|
310
|
+
_ws_publish
|
|
201
311
|
# Content unchanged since last render. In inject-on-change mode, check if
|
|
202
312
|
# we already injected this content in the current session — if so, suppress.
|
|
203
313
|
if [ "$INJECT_ON_CHANGE" != "0" ] && [ -n "$SESSION_ID" ]; then
|
|
@@ -215,17 +325,16 @@ if [ -n "$NEW_HASH" ] && [ "$NEW_HASH" = "$OLD_HASH" ] && [ -f "$BODY_FILE" ]; t
|
|
|
215
325
|
fi
|
|
216
326
|
cat "$BODY_FILE"
|
|
217
327
|
else
|
|
218
|
-
# Refresh sidecar: write hash + body, then echo body.
|
|
219
|
-
# file last so the mtime fast-skip path next turn sees a fresh
|
|
220
|
-
# mtime (newer than every source we just consumed).
|
|
328
|
+
# Refresh sidecar: write hash + body, then echo body.
|
|
221
329
|
if [ -n "$NEW_HASH" ]; then
|
|
222
|
-
|
|
223
|
-
|
|
330
|
+
_ws_write_atomic "$CACHE_FILE" "$NEW_HASH
|
|
331
|
+
"
|
|
332
|
+
_ws_publish
|
|
224
333
|
if [ "$INJECT_ON_CHANGE" != "0" ] && [ -n "$SESSION_ID" ]; then
|
|
225
334
|
_ws_record_session_state "$NEW_HASH" "$SESSION_ID"
|
|
226
335
|
fi
|
|
227
336
|
fi
|
|
228
|
-
printf '%s
|
|
337
|
+
printf '%s' "$BODY_OUT"
|
|
229
338
|
fi
|
|
230
339
|
|
|
231
340
|
exit 0
|
|
@@ -11107,7 +11107,7 @@ var AgentMemorySchema = exports_external.object({
|
|
|
11107
11107
|
recall: exports_external.object({
|
|
11108
11108
|
max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
|
|
11109
11109
|
cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
|
|
11110
|
-
min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum
|
|
11110
|
+
min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum containment token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "A cheap FLOOR that removes candidates with (near-)zero lexical " + "relationship to the prompt — NOT a precision control: at the " + "recommended 0.10 the gate is near-passthrough, and the " + "effective precision control is the engine rerank plus the " + "max_memories head-slice. 0.0 disables (default — current " + "behaviour). Use 0.10; values at or above 0.20 measurably " + "starve recall — on production replay 0.20 leaves ~41.9% of " + "turns with NO memories at all, re-creating the bug #3541 " + "fixed. Observe the `overlap_dropped` field via " + "`switchroom memory recall-log`."),
|
|
11111
11111
|
types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] — the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
|
|
11112
11112
|
additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results — e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
|
|
11113
11113
|
sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender → extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username — a leading @ is " + "optional — or numeric user_id), merged " + "into its own results — so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
|
|
@@ -11419,9 +11419,16 @@ var ReactionDispatchSchema = exports_external.object({
|
|
|
11419
11419
|
enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false — " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
|
|
11420
11420
|
emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) — a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
|
|
11421
11421
|
}).optional();
|
|
11422
|
-
var
|
|
11422
|
+
var releaseBlockFields = {
|
|
11423
11423
|
channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
|
|
11424
11424
|
pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
|
|
11425
|
+
};
|
|
11426
|
+
var ReleaseBlock = exports_external.object(releaseBlockFields).strict().refine((r) => !(r.channel && r.pin), {
|
|
11427
|
+
message: "release.channel and release.pin are mutually exclusive"
|
|
11428
|
+
});
|
|
11429
|
+
var RootReleaseBlock = exports_external.object({
|
|
11430
|
+
...releaseBlockFields,
|
|
11431
|
+
auto_update: exports_external.boolean().optional().describe("Opt-in unattended fleet auto-update (KEN-131). When true, hostd's " + "release watcher polls the published release version and, on a new " + "release, drives the EXISTING staggered canary rollout " + "(`switchroom rollout --pin vX.Y.Z`) unattended: canary-first, " + "per-agent version assert, durable pin persisted only after the " + "canary is green, abort + operator alert card on canary failure, " + "and compose rollback on a failed apply. Default false — with it " + "unset/false, behaviour is unchanged.")
|
|
11425
11432
|
}).strict().refine((r) => !(r.channel && r.pin), {
|
|
11426
11433
|
message: "release.channel and release.pin are mutually exclusive"
|
|
11427
11434
|
});
|
|
@@ -11665,6 +11672,7 @@ var AutoReleaseCheckSchema = exports_external.object({
|
|
|
11665
11672
|
enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
|
|
11666
11673
|
interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
|
|
11667
11674
|
apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
|
|
11675
|
+
notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
|
|
11668
11676
|
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
11669
11677
|
});
|
|
11670
11678
|
var HostControlConfigSchema = exports_external.object({
|
|
@@ -11707,7 +11715,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
11707
11715
|
timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC'").optional().describe("Global default IANA timezone applied to every agent unless the " + "agent (or its profile) declares its own. See the per-agent " + "timezone field for the full cascade and auto-detection fallback.")
|
|
11708
11716
|
}),
|
|
11709
11717
|
telegram: TelegramConfigSchema,
|
|
11710
|
-
release:
|
|
11718
|
+
release: RootReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely " + "(except `auto_update`, which is root-only — a fleet property read by " + "hostd's release watcher, never per-agent)."),
|
|
11711
11719
|
memory: MemoryBackendConfigSchema.optional(),
|
|
11712
11720
|
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`."),
|
|
11713
11721
|
vault: VaultConfigSchema.optional(),
|
|
@@ -16749,7 +16749,7 @@ var AgentMemorySchema = exports_external.object({
|
|
|
16749
16749
|
recall: exports_external.object({
|
|
16750
16750
|
max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
|
|
16751
16751
|
cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
|
|
16752
|
-
min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum
|
|
16752
|
+
min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum containment token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "A cheap FLOOR that removes candidates with (near-)zero lexical " + "relationship to the prompt — NOT a precision control: at the " + "recommended 0.10 the gate is near-passthrough, and the " + "effective precision control is the engine rerank plus the " + "max_memories head-slice. 0.0 disables (default — current " + "behaviour). Use 0.10; values at or above 0.20 measurably " + "starve recall — on production replay 0.20 leaves ~41.9% of " + "turns with NO memories at all, re-creating the bug #3541 " + "fixed. Observe the `overlap_dropped` field via " + "`switchroom memory recall-log`."),
|
|
16753
16753
|
types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] — the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
|
|
16754
16754
|
additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results — e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
|
|
16755
16755
|
sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender → extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username — a leading @ is " + "optional — or numeric user_id), merged " + "into its own results — so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
|
|
@@ -17061,9 +17061,16 @@ var ReactionDispatchSchema = exports_external.object({
|
|
|
17061
17061
|
enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false — " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
|
|
17062
17062
|
emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) — a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
|
|
17063
17063
|
}).optional();
|
|
17064
|
-
var
|
|
17064
|
+
var releaseBlockFields = {
|
|
17065
17065
|
channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
|
|
17066
17066
|
pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
|
|
17067
|
+
};
|
|
17068
|
+
var ReleaseBlock = exports_external.object(releaseBlockFields).strict().refine((r) => !(r.channel && r.pin), {
|
|
17069
|
+
message: "release.channel and release.pin are mutually exclusive"
|
|
17070
|
+
});
|
|
17071
|
+
var RootReleaseBlock = exports_external.object({
|
|
17072
|
+
...releaseBlockFields,
|
|
17073
|
+
auto_update: exports_external.boolean().optional().describe("Opt-in unattended fleet auto-update (KEN-131). When true, hostd's " + "release watcher polls the published release version and, on a new " + "release, drives the EXISTING staggered canary rollout " + "(`switchroom rollout --pin vX.Y.Z`) unattended: canary-first, " + "per-agent version assert, durable pin persisted only after the " + "canary is green, abort + operator alert card on canary failure, " + "and compose rollback on a failed apply. Default false — with it " + "unset/false, behaviour is unchanged.")
|
|
17067
17074
|
}).strict().refine((r) => !(r.channel && r.pin), {
|
|
17068
17075
|
message: "release.channel and release.pin are mutually exclusive"
|
|
17069
17076
|
});
|
|
@@ -17307,6 +17314,7 @@ var AutoReleaseCheckSchema = exports_external.object({
|
|
|
17307
17314
|
enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
|
|
17308
17315
|
interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
|
|
17309
17316
|
apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
|
|
17317
|
+
notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
|
|
17310
17318
|
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
17311
17319
|
});
|
|
17312
17320
|
var HostControlConfigSchema = exports_external.object({
|
|
@@ -17349,7 +17357,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
17349
17357
|
timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC'").optional().describe("Global default IANA timezone applied to every agent unless the " + "agent (or its profile) declares its own. See the per-agent " + "timezone field for the full cascade and auto-detection fallback.")
|
|
17350
17358
|
}),
|
|
17351
17359
|
telegram: TelegramConfigSchema,
|
|
17352
|
-
release:
|
|
17360
|
+
release: RootReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely " + "(except `auto_update`, which is root-only — a fleet property read by " + "hostd's release watcher, never per-agent)."),
|
|
17353
17361
|
memory: MemoryBackendConfigSchema.optional(),
|
|
17354
17362
|
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`."),
|
|
17355
17363
|
vault: VaultConfigSchema.optional(),
|
|
@@ -18357,7 +18365,23 @@ var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.
|
|
|
18357
18365
|
var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
|
|
18358
18366
|
|
|
18359
18367
|
// src/memory/hindsight.ts
|
|
18360
|
-
var DEFAULT_RETAIN_MISSION = "Extract
|
|
18368
|
+
var DEFAULT_RETAIN_MISSION = "Extract durable facts that will still be true and useful weeks from now: " + "user preferences and standing rules, ongoing projects and recurring " + "commitments, technical and architectural decisions with their rationale, " + "and people/tool relationships. A preference revealed by a request is " + "durable — record the preference (what the user likes, wants, or always " + `does), not the request itself.
|
|
18369
|
+
|
|
18370
|
+
` + `NEVER extract:
|
|
18371
|
+
` + "- Agent tool-use traces or narration of what the assistant did (e.g. " + `"the assistant used X to query Y", "ran a search", "sent the message").
|
|
18372
|
+
` + "- In-flight workflow/process narration (a sub-task started, paused, or is " + "still running) — retain the outcome only once the task completes or a " + `decision is made.
|
|
18373
|
+
` + `- Operation, request, batch or session IDs, UUIDs, hashes, or error codes.
|
|
18374
|
+
` + "- Hindsight's own errors, retries, backlogs, or internal state — the " + `memory system's self-reports are not memories.
|
|
18375
|
+
` + `- Restatements of the user's current request or the task in progress.
|
|
18376
|
+
` + "- Transient state (unread counts, build status, what is running right now) " + "unless the fact is explicitly dated, in which case record it as a dated " + `observation.
|
|
18377
|
+
` + `- Greetings, acknowledgements, and routine operational chatter.
|
|
18378
|
+
|
|
18379
|
+
` + "If a candidate fact matches an exclusion, drop it rather than rewording " + "it. If nothing durable remains, return an empty facts list.";
|
|
18380
|
+
var SUPERSEDED_RETAIN_MISSIONS = [
|
|
18381
|
+
"Extract technical decisions, architectural choices, user preferences, project context, and people/tool relationships. Ignore routine greetings and transient operational details.",
|
|
18382
|
+
"Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise.",
|
|
18383
|
+
"Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) — only retain the outcome once a task " + "actually completes or a decision is made."
|
|
18384
|
+
];
|
|
18361
18385
|
var PROFILE_MEMORY_DEFAULTS = {
|
|
18362
18386
|
"health-coach": {
|
|
18363
18387
|
disposition: { skepticism: 2, literalism: 2, empathy: 5 },
|
|
@@ -18742,6 +18766,7 @@ var materializedDirs = new Set;
|
|
|
18742
18766
|
// src/agents/scaffold.ts
|
|
18743
18767
|
var REPO_ROOT = resolve5(import.meta.dirname, "../..");
|
|
18744
18768
|
var CLAUDE_MD_YOURS_PLACEHOLDER = "This space is yours. Add per-agent rules, exceptions, or context the " + "Switchroom template doesn't capture. Everything above the marker line is " + "regenerated on every apply; this section is preserved.";
|
|
18769
|
+
var pendingBankOps = new Set;
|
|
18745
18770
|
var SWITCHROOM_OWNED_SETTINGS_KEYS = new Set([
|
|
18746
18771
|
"permissions",
|
|
18747
18772
|
"mcpServers",
|
|
@@ -11865,7 +11865,7 @@ var AgentMemorySchema = exports_external.object({
|
|
|
11865
11865
|
recall: exports_external.object({
|
|
11866
11866
|
max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
|
|
11867
11867
|
cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
|
|
11868
|
-
min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum
|
|
11868
|
+
min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum containment token overlap [0.0\u20131.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "A cheap FLOOR that removes candidates with (near-)zero lexical " + "relationship to the prompt \u2014 NOT a precision control: at the " + "recommended 0.10 the gate is near-passthrough, and the " + "effective precision control is the engine rerank plus the " + "max_memories head-slice. 0.0 disables (default \u2014 current " + "behaviour). Use 0.10; values at or above 0.20 measurably " + "starve recall \u2014 on production replay 0.20 leaves ~41.9% of " + "turns with NO memories at all, re-creating the bug #3541 " + "fixed. Observe the `overlap_dropped` field via " + "`switchroom memory recall-log`."),
|
|
11869
11869
|
types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] \u2014 the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
|
|
11870
11870
|
additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results \u2014 e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
|
|
11871
11871
|
sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender \u2192 extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username \u2014 a leading @ is " + "optional \u2014 or numeric user_id), merged " + "into its own results \u2014 so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
|
|
@@ -12177,9 +12177,16 @@ var ReactionDispatchSchema = exports_external.object({
|
|
|
12177
12177
|
enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false \u2014 " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
|
|
12178
12178
|
emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) \u2014 a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
|
|
12179
12179
|
}).optional();
|
|
12180
|
-
var
|
|
12180
|
+
var releaseBlockFields = {
|
|
12181
12181
|
channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
|
|
12182
12182
|
pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
|
|
12183
|
+
};
|
|
12184
|
+
var ReleaseBlock = exports_external.object(releaseBlockFields).strict().refine((r) => !(r.channel && r.pin), {
|
|
12185
|
+
message: "release.channel and release.pin are mutually exclusive"
|
|
12186
|
+
});
|
|
12187
|
+
var RootReleaseBlock = exports_external.object({
|
|
12188
|
+
...releaseBlockFields,
|
|
12189
|
+
auto_update: exports_external.boolean().optional().describe("Opt-in unattended fleet auto-update (KEN-131). When true, hostd's " + "release watcher polls the published release version and, on a new " + "release, drives the EXISTING staggered canary rollout " + "(`switchroom rollout --pin vX.Y.Z`) unattended: canary-first, " + "per-agent version assert, durable pin persisted only after the " + "canary is green, abort + operator alert card on canary failure, " + "and compose rollback on a failed apply. Default false \u2014 with it " + "unset/false, behaviour is unchanged.")
|
|
12183
12190
|
}).strict().refine((r) => !(r.channel && r.pin), {
|
|
12184
12191
|
message: "release.channel and release.pin are mutually exclusive"
|
|
12185
12192
|
});
|
|
@@ -12423,6 +12430,7 @@ var AutoReleaseCheckSchema = exports_external.object({
|
|
|
12423
12430
|
enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false \u2014 opt-in."),
|
|
12424
12431
|
interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
|
|
12425
12432
|
apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
|
|
12433
|
+
notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 \u2014 operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind \u2014 tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
|
|
12426
12434
|
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
12427
12435
|
});
|
|
12428
12436
|
var HostControlConfigSchema = exports_external.object({
|
|
@@ -12465,7 +12473,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
12465
12473
|
timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC'").optional().describe("Global default IANA timezone applied to every agent unless the " + "agent (or its profile) declares its own. See the per-agent " + "timezone field for the full cascade and auto-detection fallback.")
|
|
12466
12474
|
}),
|
|
12467
12475
|
telegram: TelegramConfigSchema,
|
|
12468
|
-
release:
|
|
12476
|
+
release: RootReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "\u2014 mutually exclusive. Per-agent `release` REPLACES this entirely " + "(except `auto_update`, which is root-only \u2014 a fleet property read by " + "hostd's release watcher, never per-agent)."),
|
|
12469
12477
|
memory: MemoryBackendConfigSchema.optional(),
|
|
12470
12478
|
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) \u2014 takes effect on " + "the next `switchroom apply` / `memory setup --recreate`."),
|
|
12471
12479
|
vault: VaultConfigSchema.optional(),
|