switchroom 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -57,8 +57,18 @@ import {
57
57
  applyAndSave as saveBootIssueCache,
58
58
  type ProbeDiffMap,
59
59
  } from './boot-issue-cache.js'
60
+ import {
61
+ loadSnapshot as loadConfigSnapshot,
62
+ persistSnapshot as persistConfigSnapshot,
63
+ captureConfigSnapshot,
64
+ diffSnapshots as diffConfigSnapshots,
65
+ renderConfigChangeDim,
66
+ type ConfigSnapshot,
67
+ type ConfigDiff,
68
+ } from './config-snapshot.js'
60
69
  import { join } from 'path'
61
70
  import { loadConfig as _loadSwitchroomConfig } from '../../src/config/loader.js'
71
+ import { resolveAgentConfig as _resolveAgentConfig } from '../../src/config/merge.js'
62
72
 
63
73
  // ─── Persona name resolution ─────────────────────────────────────────────────
64
74
 
@@ -298,6 +308,16 @@ export interface RenderBootCardOpts {
298
308
  * the operator sees what happened with the update that triggered
299
309
  * this boot without trawling the audit log. */
300
310
  updateOutcomeLine?: string
311
+ /**
312
+ * Config-change dimensions detected since the previous boot.
313
+ * One row per changed field (model, tools, skills, memory backend).
314
+ * Empty array / undefined = no config changes → section is silent,
315
+ * preserving the "silent-when-healthy" boot card contract.
316
+ *
317
+ * Computed by diffing the current config snapshot against the
318
+ * persisted snapshot from the prior boot. See `config-snapshot.ts`.
319
+ */
320
+ configChanges?: ConfigDiff
301
321
  }
302
322
 
303
323
  /**
@@ -403,6 +423,17 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
403
423
  ? renderAuthLine(opts.accounts, agentName, (opts.now ?? new Date()).getTime())
404
424
  : []
405
425
 
426
+ // Config-change rows (E3: boot card surfaces config changes since last boot).
427
+ // Only rendered when the diff is non-empty — silent on identical boots.
428
+ // Each changed dimension gets its own row; model and memory backend show
429
+ // verbatim before/after; tools and skills show a coarse hash-diff hint.
430
+ const configChangeRows: string[] = []
431
+ if (opts.configChanges && opts.configChanges.length > 0) {
432
+ for (const dim of opts.configChanges) {
433
+ configChangeRows.push(renderConfigChangeDim(dim))
434
+ }
435
+ }
436
+
406
437
  const sections: string[] = [ack]
407
438
  if (degradedRows.length > 0) sections.push('', ...degradedRows)
408
439
  if (accountRows.length > 0) sections.push('', ...accountRows)
@@ -412,6 +443,9 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
412
443
  // failure-with-recovery hint cleanly.
413
444
  sections.push('', ...opts.updateOutcomeLine.split('\n'))
414
445
  }
446
+ if (configChangeRows.length > 0) {
447
+ sections.push('', ...configChangeRows)
448
+ }
415
449
  if (sections.length === 1) return ack
416
450
  return sections.join('\n')
417
451
  }
@@ -520,6 +554,20 @@ export interface RunProbesOpts {
520
554
  * failure body with a recovery hint. Append-only — never replaces
521
555
  * the existing ack/probe sections. */
522
556
  updateOutcomeLine?: string
557
+ /**
558
+ * Path to the per-agent config snapshot file. When set, the post-settle
559
+ * render diffs the current resolved config against the persisted snapshot
560
+ * from the prior boot and appends a row per changed dimension (model,
561
+ * tools allowlist, skills, memory backend).
562
+ *
563
+ * Omit to disable config-change detection entirely (legacy behaviour /
564
+ * test harnesses that don't need it).
565
+ *
566
+ * Typically `<agentDir>/.config-snapshot.json`. Must be provided
567
+ * alongside `agentName` — the capture function reads `agentName` to
568
+ * resolve the default memory collection label.
569
+ */
570
+ configSnapshotPath?: string
523
571
  }
524
572
 
525
573
  /** Run all six probes concurrently with their own per-probe timeouts.
@@ -670,6 +718,53 @@ export async function startBootCard(
670
718
  }
671
719
  }
672
720
 
721
+ // Config-snapshot diff (E3): compare this boot's resolved config
722
+ // against the snapshot persisted on the previous boot. Fires once
723
+ // per gateway lifetime — read up-front, write on the way out.
724
+ // Silent on first boot (no prior snapshot) and on identical boots.
725
+ let configChanges: ConfigDiff = []
726
+ if (opts.configSnapshotPath) {
727
+ try {
728
+ const agentSlug = opts.agentSlug ?? opts.agentName
729
+ const agentName = process.env.SWITCHROOM_AGENT_NAME ?? agentSlug
730
+ let currentCfg: ConfigSnapshot | undefined
731
+ try {
732
+ const loaded = _loadSwitchroomConfig()
733
+ const rawAgent = loaded.agents?.[agentName] ?? {}
734
+ const resolved = _resolveAgentConfig(loaded.defaults, loaded.profiles, rawAgent)
735
+ currentCfg = captureConfigSnapshot({
736
+ agentName,
737
+ model: resolved.model,
738
+ toolsAllow: resolved.tools?.allow,
739
+ skills: resolved.skills,
740
+ memoryCollection: resolved.memory?.collection,
741
+ })
742
+ } catch {
743
+ // Config unreadable (test env, no switchroom.yaml) — skip diff.
744
+ }
745
+ if (currentCfg != null) {
746
+ const previousSnapshot = loadConfigSnapshot(opts.configSnapshotPath)
747
+ configChanges = diffConfigSnapshots(currentCfg, previousSnapshot)
748
+ // Persist unconditionally so the next boot always has a
749
+ // fresh baseline. A failed persist is non-fatal.
750
+ persistConfigSnapshot(opts.configSnapshotPath, currentCfg)
751
+ if (configChanges.length > 0) {
752
+ logger(
753
+ `telegram gateway: boot-card: config-snapshot diff detected ${configChanges.length} change(s): ${
754
+ configChanges.map(d => d.field).join(', ')
755
+ }\n`,
756
+ )
757
+ }
758
+ }
759
+ } catch (snapErr: unknown) {
760
+ logger(
761
+ `telegram gateway: boot-card: config-snapshot diff failed: ${
762
+ (snapErr as Error)?.message ?? String(snapErr)
763
+ }\n`,
764
+ )
765
+ }
766
+ }
767
+
673
768
  // Render with current probe state and edit if anything changed.
674
769
  let currentText = renderBootCard({
675
770
  agentName: opts.agentName,
@@ -682,6 +777,7 @@ export async function startBootCard(
682
777
  ...(resolvedRows.length > 0 ? { resolvedRows } : {}),
683
778
  ...(snoozeRows.length > 0 ? { snoozeRows } : {}),
684
779
  ...(opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}),
780
+ ...(configChanges.length > 0 ? { configChanges } : {}),
685
781
  })
686
782
 
687
783
  if (currentText !== ackText) {
@@ -734,6 +830,10 @@ export async function startBootCard(
734
830
  ...(resolvedRows.length > 0 ? { resolvedRows } : {}),
735
831
  ...(snoozeRows.length > 0 ? { snoozeRows } : {}),
736
832
  ...(opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}),
833
+ // Config-change rows are stable for the lifetime of this card
834
+ // (computed once in Phase 1). Pass them through unchanged so
835
+ // the live-agent-status edits keep the config-diff rows.
836
+ ...(configChanges.length > 0 ? { configChanges } : {}),
737
837
  })
738
838
 
739
839
  if (updatedText === currentText) continue
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Config-snapshot cache — captures a fingerprint of each agent's
3
+ * active configuration at gateway boot and diffs against the prior boot.
4
+ *
5
+ * Closes the JTBD "no need to ask" gap in `restart-and-know-what-im-running`:
6
+ * a user who restarts after editing switchroom.yaml now sees exactly what
7
+ * changed on the boot card, without running `/status`.
8
+ *
9
+ * What's captured:
10
+ * - model slug (single string, shown verbatim in the diff row)
11
+ * - tools allowlist hash (sorted SHA-256 prefix — coarse diff only;
12
+ * granular enumeration is a follow-up)
13
+ * - skills hash (same pattern)
14
+ * - memory backend / collection name (single string, shown verbatim)
15
+ *
16
+ * Diff policy:
17
+ * - model / memoryBackend: shown verbatim ("model: A → B")
18
+ * - toolsHash / skillsHash: "tools allowlist changed — run /status for
19
+ * details" (honest without being exhaustive for long allowlists)
20
+ *
21
+ * Silent when unchanged: the caller ONLY surfaces a row when
22
+ * `diffSnapshots()` returns at least one `ConfigChangeDim`.
23
+ *
24
+ * First boot (no prior snapshot): returns an empty diff and persists
25
+ * the current snapshot for the next boot. Corrupt / missing snapshot:
26
+ * treated as first boot.
27
+ *
28
+ * Storage: `<agentDir>/.config-snapshot.json` (mode 0600), same
29
+ * location pattern as `boot-issue-cache.json`.
30
+ */
31
+
32
+ import { createHash } from 'crypto'
33
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'
34
+ import { dirname } from 'path'
35
+ import { escapeHtml } from '../card-format.js'
36
+
37
+ // ─── Types ──────────────────────────────────────────────────────────────────
38
+
39
+ export interface ConfigSnapshot {
40
+ /** Schema version — bump on incompatible layout changes. */
41
+ schema: 1
42
+ /** Wall-clock ms at the time of capture (for forensics / GC). */
43
+ capturedAtMs: number
44
+ /** Resolved model slug, e.g. "claude-sonnet-4-5".
45
+ * null when not set in config (gateway uses the claude CLI default). */
46
+ model: string | null
47
+ /**
48
+ * Sorted SHA-256 prefix of the tools allowlist.
49
+ * Null when the allowlist is unset / empty (uses claude CLI default).
50
+ * Computed from the sorted join of the array so reordering the YAML
51
+ * does not fire a spurious diff.
52
+ */
53
+ toolsHash: string | null
54
+ /**
55
+ * Sorted SHA-256 prefix of the skills list.
56
+ * Null when no skills are configured.
57
+ */
58
+ skillsHash: string | null
59
+ /**
60
+ * Memory backend identifier — `memory.collection` from switchroom.yaml,
61
+ * or null when not configured.
62
+ */
63
+ memoryBackend: string | null
64
+ }
65
+
66
+ /** One dimension that changed between the prior snapshot and this boot. */
67
+ export interface ConfigChangeDim {
68
+ /** Which field changed. */
69
+ field: 'model' | 'tools' | 'skills' | 'memoryBackend'
70
+ /** Previous value (null = "not configured" or "first boot"). */
71
+ from: string | null
72
+ /** Current value (null = "removed" — field was cleared in config). */
73
+ to: string | null
74
+ }
75
+
76
+ export type ConfigDiff = ConfigChangeDim[]
77
+
78
+ // ─── Hashing ────────────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Compute a short deterministic fingerprint for an array of strings.
82
+ * Sort before hashing so reordering the array in YAML doesn't fire a
83
+ * spurious diff row. Returns null for empty / null input.
84
+ */
85
+ export function hashStringArray(items: string[] | null | undefined): string | null {
86
+ if (!items || items.length === 0) return null
87
+ const sorted = [...items].sort()
88
+ const raw = createHash('sha256').update(sorted.join('\0')).digest('hex')
89
+ // 12-char prefix is visually compact and collision-resistant enough for
90
+ // config arrays that rarely exceed a few dozen entries.
91
+ return raw.slice(0, 12)
92
+ }
93
+
94
+ /**
95
+ * Normalize a model slug for comparison: lowercase, strip trailing
96
+ * whitespace, collapse multiple spaces. Prevents spurious diffs from
97
+ * case/whitespace variance in the YAML value.
98
+ */
99
+ export function normalizeModel(model: string | null | undefined): string | null {
100
+ if (!model || model.trim().length === 0) return null
101
+ return model.trim().toLowerCase()
102
+ }
103
+
104
+ // ─── Capture ────────────────────────────────────────────────────────────────
105
+
106
+ export interface CaptureInput {
107
+ /** Agent name — used for the default memoryBackend when not explicitly set. */
108
+ agentName: string
109
+ /** Resolved model from the config cascade (may be undefined/null). */
110
+ model?: string | null
111
+ /** Full tools allowlist from the config cascade (may be undefined/null). */
112
+ toolsAllow?: string[] | null
113
+ /** Full skills list from the config cascade (may be undefined/null). */
114
+ skills?: string[] | null
115
+ /** Memory collection name from the config cascade (may be undefined/null). */
116
+ memoryCollection?: string | null
117
+ /** Clock injection for tests. */
118
+ now?: () => number
119
+ }
120
+
121
+ /**
122
+ * Build a ConfigSnapshot from the current resolved agent config.
123
+ *
124
+ * The caller is responsible for loading the config and passing the
125
+ * relevant fields. This function is pure — no disk I/O.
126
+ */
127
+ export function captureConfigSnapshot(input: CaptureInput): ConfigSnapshot {
128
+ return {
129
+ schema: 1,
130
+ capturedAtMs: (input.now ?? Date.now)(),
131
+ model: normalizeModel(input.model),
132
+ toolsHash: hashStringArray(input.toolsAllow ?? null),
133
+ skillsHash: hashStringArray(input.skills ?? null),
134
+ memoryBackend: input.memoryCollection?.trim() || null,
135
+ }
136
+ }
137
+
138
+ // ─── Diff ────────────────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Compare `current` against `previous` and return one entry per changed
142
+ * dimension. Returns an empty array when nothing changed.
143
+ *
144
+ * Edge cases:
145
+ * - `previous` is null (first boot or corrupt cache): returns empty diff.
146
+ * The caller will persist `current` for the next boot.
147
+ * - A field is null on both sides: not a change.
148
+ */
149
+ export function diffSnapshots(
150
+ current: ConfigSnapshot,
151
+ previous: ConfigSnapshot | null,
152
+ ): ConfigDiff {
153
+ if (previous === null) return []
154
+
155
+ const changes: ConfigDiff = []
156
+
157
+ if (current.model !== previous.model) {
158
+ changes.push({ field: 'model', from: previous.model, to: current.model })
159
+ }
160
+ if (current.toolsHash !== previous.toolsHash) {
161
+ changes.push({ field: 'tools', from: previous.toolsHash, to: current.toolsHash })
162
+ }
163
+ if (current.skillsHash !== previous.skillsHash) {
164
+ changes.push({ field: 'skills', from: previous.skillsHash, to: current.skillsHash })
165
+ }
166
+ if (current.memoryBackend !== previous.memoryBackend) {
167
+ changes.push({
168
+ field: 'memoryBackend',
169
+ from: previous.memoryBackend,
170
+ to: current.memoryBackend,
171
+ })
172
+ }
173
+
174
+ return changes
175
+ }
176
+
177
+ // ─── Rendering ───────────────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Render a single config-change row for the boot card.
181
+ *
182
+ * Model and memory backend: shown verbatim ("model: A → B").
183
+ * Tools / skills: coarse hash diff ("tools allowlist changed — run /status
184
+ * for details"), since enumerating the full list would be too verbose.
185
+ *
186
+ * The null-to-value case means "field was first configured on this boot".
187
+ * The value-to-null case means "field was removed" (unusual but legal).
188
+ */
189
+ export function renderConfigChangeDim(dim: ConfigChangeDim): string {
190
+ switch (dim.field) {
191
+ case 'model': {
192
+ const from = escapeHtml(dim.from ?? '(default)')
193
+ const to = escapeHtml(dim.to ?? '(default)')
194
+ return `⚙️ <b>Config</b> model: ${from} → ${to}`
195
+ }
196
+ case 'memoryBackend': {
197
+ const from = escapeHtml(dim.from ?? '(default)')
198
+ const to = escapeHtml(dim.to ?? '(default)')
199
+ return `⚙️ <b>Config</b> memory backend: ${from} → ${to}`
200
+ }
201
+ case 'tools':
202
+ return `⚙️ <b>Config</b> tools allowlist changed — run /status for details`
203
+ case 'skills':
204
+ return `⚙️ <b>Config</b> skills changed — run /status for details`
205
+ }
206
+ }
207
+
208
+ // ─── Persistence ─────────────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Load the config snapshot from `path`.
212
+ *
213
+ * Returns null on:
214
+ * - file missing (first boot)
215
+ * - JSON parse error (corrupt — renamed aside for forensics)
216
+ * - schema mismatch
217
+ */
218
+ export function loadSnapshot(path: string, now: () => number = Date.now): ConfigSnapshot | null {
219
+ if (!existsSync(path)) return null
220
+ let raw: string
221
+ try {
222
+ raw = readFileSync(path, 'utf-8')
223
+ } catch {
224
+ return null
225
+ }
226
+ let parsed: unknown
227
+ try {
228
+ parsed = JSON.parse(raw)
229
+ } catch {
230
+ // Corrupt — preserve for forensics, return null (treat as first boot).
231
+ try {
232
+ renameSync(path, `${path}.corrupt-${now()}`)
233
+ } catch {
234
+ // best-effort
235
+ }
236
+ return null
237
+ }
238
+ const obj = parsed as Partial<ConfigSnapshot>
239
+ if (!obj || obj.schema !== 1) return null
240
+ // Validate required keys exist (defensive against partial writes).
241
+ if (
242
+ typeof obj.capturedAtMs !== 'number' ||
243
+ !('model' in obj) ||
244
+ !('toolsHash' in obj) ||
245
+ !('skillsHash' in obj) ||
246
+ !('memoryBackend' in obj)
247
+ ) {
248
+ return null
249
+ }
250
+ return {
251
+ schema: 1,
252
+ capturedAtMs: obj.capturedAtMs,
253
+ model: obj.model ?? null,
254
+ toolsHash: obj.toolsHash ?? null,
255
+ skillsHash: obj.skillsHash ?? null,
256
+ memoryBackend: obj.memoryBackend ?? null,
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Persist `snapshot` to `path` atomically (via `.tmp` + rename).
262
+ * Failures are swallowed — the snapshot is best-effort and should not
263
+ * block the boot sequence.
264
+ */
265
+ export function persistSnapshot(path: string, snapshot: ConfigSnapshot): void {
266
+ try {
267
+ mkdirSync(dirname(path), { recursive: true })
268
+ const tmp = `${path}.tmp`
269
+ writeFileSync(tmp, JSON.stringify(snapshot), { mode: 0o600 })
270
+ renameSync(tmp, path)
271
+ } catch {
272
+ // Non-fatal: best-effort persistence. Next boot will re-detect.
273
+ }
274
+ }