switchroom 0.18.28 → 0.18.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/bin/handoff-briefing.sh +15 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -73
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2712 -2219
  9. package/dist/host-control/main.js +110 -70
  10. package/dist/vault/approvals/kernel-server.js +116 -70
  11. package/dist/vault/broker/server.js +314 -202
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +105 -34
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +1128 -666
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  18. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  19. package/telegram-plugin/gateway/gateway.ts +656 -388
  20. package/telegram-plugin/gateway/model-command.ts +331 -602
  21. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  22. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  23. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  24. package/telegram-plugin/history.ts +153 -23
  25. package/telegram-plugin/llm-error-present.ts +24 -0
  26. package/telegram-plugin/model-unavailable.ts +55 -0
  27. package/telegram-plugin/operator-events.ts +113 -0
  28. package/telegram-plugin/pending-user-notice.ts +88 -0
  29. package/telegram-plugin/shared/local-time.ts +99 -0
  30. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  31. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  32. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  34. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
  35. package/telegram-plugin/tests/history.test.ts +88 -0
  36. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  37. package/telegram-plugin/tests/local-time.test.ts +135 -0
  38. package/telegram-plugin/tests/model-command.test.ts +427 -1512
  39. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  41. package/telegram-plugin/tier-downgrade.ts +4 -3
  42. package/telegram-plugin/turn-flush-safety.ts +25 -1
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  44. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  45. package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
  46. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  48. package/vendor/hindsight-memory/tests/test_content.py +63 -7
@@ -8,8 +8,14 @@ truncateRecallQuery, sliceLastTurnsByUserBoundary, prepareRetentionTranscript,
8
8
  formatMemories.
9
9
  """
10
10
 
11
+ import os
11
12
  import re
12
- from datetime import datetime, timezone
13
+ from datetime import datetime
14
+
15
+ try: # Python 3.9+; present in every switchroom agent image.
16
+ from zoneinfo import ZoneInfo
17
+ except ImportError: # pragma: no cover - defensive only
18
+ ZoneInfo = None # type: ignore[assignment,misc]
13
19
 
14
20
  # ---------------------------------------------------------------------------
15
21
  # Memory tag stripping (anti-feedback-loop)
@@ -249,21 +255,101 @@ def format_memories(results: list) -> str:
249
255
  mem_type = r.get("type", "")
250
256
  mentioned_at = r.get("mentioned_at", "")
251
257
  type_str = f" [{mem_type}]" if mem_type else ""
252
- date_str = f" ({mentioned_at})" if mentioned_at else ""
258
+ # switchroom #tz-fix (recall side): mentioned_at arrives as a UTC ISO
259
+ # timestamp from the Hindsight server. Render it through the same
260
+ # SWITCHROOM_TIMEZONE→TZ→UTC zoneinfo conversion as format_current_time
261
+ # so recall lines never inject a UTC "when". Date-only / unparseable
262
+ # values are surfaced verbatim rather than crashing recall.
263
+ display_at = _format_local_timestamp(mentioned_at) if mentioned_at else ""
264
+ date_str = f" ({display_at})" if display_at else ""
253
265
  lines.append(f"- {text}{type_str}{date_str}")
254
266
  return "\n\n".join(lines)
255
267
 
256
268
 
269
+ def _resolve_agent_timezone() -> str:
270
+ """Resolve the agent's configured IANA timezone.
271
+
272
+ Mirrors the switchroom cascade used by ``bin/timezone-hook.sh`` and
273
+ ``src/config/timezone.ts``: ``SWITCHROOM_TIMEZONE`` → ``TZ`` → ``UTC``.
274
+ This recall hook runs as a plugin subprocess INSIDE the agent container,
275
+ so it inherits both env vars (compose.ts bakes them onto the container).
276
+ """
277
+ return os.environ.get("SWITCHROOM_TIMEZONE") or os.environ.get("TZ") or "UTC"
278
+
279
+
280
+ def _format_local_timestamp(value: str) -> str:
281
+ """Render a UTC ISO timestamp in the agent's LOCAL timezone (am/pm form).
282
+
283
+ Used by ``format_memories`` to convert the server-supplied ``mentioned_at``
284
+ (UTC ISO, e.g. ``2026-07-16T04:09:00Z``) through the same
285
+ SWITCHROOM_TIMEZONE→TZ→UTC zoneinfo cascade as ``format_current_time``,
286
+ so recalled memories never surface a UTC "when".
287
+
288
+ Guarding: only full ISO *datetime* values (those carrying a time component,
289
+ i.e. containing ``T``) are converted. Date-only strings (``2024-01-01``) and
290
+ anything unparseable are returned verbatim rather than crashing recall or
291
+ fabricating a midnight time.
292
+ """
293
+ if not isinstance(value, str):
294
+ return value
295
+ raw = value.strip()
296
+ # Only convert full ISO datetimes — a bare date has no wall-clock to shift.
297
+ if "T" not in raw:
298
+ return value
299
+ parsed = None
300
+ try:
301
+ # Python <3.11 fromisoformat rejects a trailing 'Z'; normalise it.
302
+ iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
303
+ parsed = datetime.fromisoformat(iso)
304
+ except (ValueError, TypeError):
305
+ return value
306
+ # Naive value → server sends UTC, so assume UTC before converting.
307
+ if parsed.tzinfo is None:
308
+ if ZoneInfo is not None:
309
+ try:
310
+ parsed = parsed.replace(tzinfo=ZoneInfo("UTC"))
311
+ except Exception:
312
+ return value
313
+ else:
314
+ return value
315
+ tz_name = _resolve_agent_timezone()
316
+ if ZoneInfo is not None:
317
+ try:
318
+ local = parsed.astimezone(ZoneInfo(tz_name))
319
+ except Exception: # unknown/invalid zone — degrade to process-local.
320
+ local = parsed.astimezone()
321
+ else:
322
+ local = parsed.astimezone()
323
+ return local.strftime("%Y-%m-%d %I:%M %p %Z")
324
+
325
+
257
326
  def format_current_time() -> str:
258
- """Format current UTC time for recall context.
327
+ """Format the current time in the agent's LOCAL timezone for recall context.
259
328
 
260
- The "UTC" suffix is explicit so client LLMs do not misread the
261
- value as local time when reasoning about wall-clock context.
329
+ Switchroom #tz-fix: previously this emitted ``"%Y-%m-%d %H:%M UTC"``, the
330
+ STRONGEST of several competing UTC "current time" strings the model saw
331
+ each turn — it fought the single correct local-time hint and made agents
332
+ intermittently report UTC / wrong-offset times. We now render the agent's
333
+ LOCAL wall clock in am/pm form (e.g. ``2026-07-16 04:09 PM AEST``) so the
334
+ recall block never injects a UTC "now". Deterministic — the timezone comes
335
+ from the container env, not model discipline.
262
336
 
263
337
  Port of: formatCurrentTimeForRecall() in index.js
264
338
  """
265
- now = datetime.now(timezone.utc)
266
- return now.strftime("%Y-%m-%d %H:%M UTC")
339
+ tz_name = _resolve_agent_timezone()
340
+ now = None
341
+ if ZoneInfo is not None:
342
+ try:
343
+ now = datetime.now(ZoneInfo(tz_name))
344
+ except Exception: # unknown/invalid zone — degrade, don't crash recall.
345
+ now = None
346
+ if now is None:
347
+ # No zoneinfo or bad zone: fall back to the process-local clock, which
348
+ # already honours the container's TZ env via libc. am/pm form, no "UTC".
349
+ now = datetime.now().astimezone()
350
+ # "%Y-%m-%d %I:%M %p %Z" → e.g. "2026-07-16 04:09 PM AEST" (mirrors the
351
+ # %I:%M %p %Z tail of bin/timezone-hook.sh's format).
352
+ return now.strftime("%Y-%m-%d %I:%M %p %Z")
267
353
 
268
354
 
269
355
  # ---------------------------------------------------------------------------
@@ -0,0 +1,450 @@
1
+ """Turn-registry-driven candidate discovery for the log-driven recovery mode
2
+ (switchroom #3244 follow-up, ``backfill_transcripts.py --from-logs``).
3
+
4
+ This is the "narrow the candidate set with the durable turn-lifecycle log, then
5
+ let SESSION-LEVEL bank membership be the real safety gate" half of the recovery
6
+ tool. It never writes anything and issues no HTTP; it only reads the per-agent
7
+ Telegram turns registry (``registry.db``, opened READ-ONLY) and the on-disk
8
+ ``.jsonl`` transcripts, and resolves each agent's TRUE bank from
9
+ ``switchroom.yaml``.
10
+
11
+ Three design decisions are load-bearing and fold in the design red-team's three
12
+ must-fixes (``log-driven-recovery-design-review-20260715.md``):
13
+
14
+ 1. **BROAD candidate classifier (must-fix #1).** The design's original narrowing
15
+ predicate — ``ended_via='restart' AND tool_call_count>0 AND
16
+ last_assistant_done=0`` — is DEAD on real data: both columns are NULL on 100%
17
+ of deployed ``restart`` rows, so it matches nothing. We do NOT sub-classify
18
+ ``restart``. A row is a candidate iff it is anything other than a clean,
19
+ closed ``stop``: ``ended_via IS NULL`` OR ``ended_via <> 'stop'`` OR
20
+ ``ended_at IS NULL``. Cost stays bounded because slice-level membership
21
+ (a cheap, server-side-filtered GET) is what actually decides restoration, and
22
+ only genuinely-absent slices re-extract.
23
+
24
+ 2. **Event-span CONTAINMENT join (must-fix #3).** A registry turn is ONE turn
25
+ inside a multi-turn session; the transcript ``mtime`` is the last write of the
26
+ WHOLE session, so an ``mtime``-within-window match MISSES the incident turn.
27
+ Instead we resolve the turn's transcript by span containment: the transcript
28
+ whose ``[first_event_ts, last_event_ts]`` CONTAINS the turn's ``started_at``,
29
+ filtered by agent + chat/thread. An agent runs one session at a time, so this
30
+ is unambiguous by construction; 0 or >1 matches ⇒ REFUSE (reported, not
31
+ guessed).
32
+
33
+ 3. **Correct-bank targeting.** ``resolve_true_bank`` reads the agent's own row in
34
+ ``switchroom.yaml`` (``memory.collection ?? name``), cross-checks the agent's
35
+ ``.claude/settings.json`` ``X-Bank-Id`` header, and REFUSES (returns an error
36
+ reason, zero writes) on disagreement, a dynamic bank, or a missing row. Never
37
+ the ambient env / stale plugin default.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import os
44
+ import sqlite3
45
+ from dataclasses import dataclass
46
+ from datetime import datetime, timezone
47
+ from typing import Optional
48
+
49
+
50
+ # --------------------------------------------------------------------------- #
51
+ # Candidate classification — BROAD (must-fix #1).
52
+ # --------------------------------------------------------------------------- #
53
+ # A row is a candidate for recovery iff it is NOT a clean, closed ``stop``. We
54
+ # deliberately do NOT try to sub-classify ``restart`` by the in-flight columns
55
+ # (``last_assistant_done`` / ``tool_call_count``) — both are NULL on all deployed
56
+ # ``restart`` rows, so any such predicate matches nothing. Membership is the real
57
+ # gate, so a broad candidate set is safe and only costs cheap GETs.
58
+ _CANDIDATE_WHERE = (
59
+ "(ended_via IS NULL OR ended_via <> 'stop' OR ended_at IS NULL)"
60
+ )
61
+
62
+
63
+ @dataclass
64
+ class CandidateTurn:
65
+ """One flagged turn from the registry (not yet joined to a transcript)."""
66
+
67
+ turn_key: str
68
+ chat_id: Optional[str]
69
+ thread_id: Optional[str]
70
+ started_at: Optional[int] # epoch ms
71
+ ended_at: Optional[int] # epoch ms (None ⇒ null-open)
72
+ ended_via: Optional[str]
73
+ session_id: Optional[str] = None # populated only on migrated registries
74
+
75
+
76
+ def _columns(conn: sqlite3.Connection) -> set:
77
+ try:
78
+ return {r[1] for r in conn.execute("PRAGMA table_info(turns)")}
79
+ except sqlite3.Error:
80
+ return set()
81
+
82
+
83
+ def read_candidate_turns(registry_path: str) -> list:
84
+ """Read the BROAD candidate set from a per-agent ``registry.db`` (READ-ONLY).
85
+
86
+ Returns a list of ``CandidateTurn``. Opens the SQLite DB in ``mode=ro`` so it
87
+ can never mutate a live registry. A missing / unreadable DB yields ``[]``.
88
+ """
89
+ if not registry_path or not os.path.isfile(registry_path):
90
+ return []
91
+ uri = f"file:{registry_path}?mode=ro"
92
+ try:
93
+ conn = sqlite3.connect(uri, uri=True, timeout=5)
94
+ except sqlite3.Error:
95
+ return []
96
+ try:
97
+ cols = _columns(conn)
98
+ if "turn_key" not in cols:
99
+ return []
100
+ has_session = "session_id" in cols
101
+ select_cols = [
102
+ "turn_key", "chat_id", "thread_id", "started_at",
103
+ "ended_at", "ended_via",
104
+ ]
105
+ if has_session:
106
+ select_cols.append("session_id")
107
+ sql = f"SELECT {', '.join(select_cols)} FROM turns WHERE {_CANDIDATE_WHERE}"
108
+ rows = conn.execute(sql).fetchall()
109
+ except sqlite3.Error:
110
+ return []
111
+ finally:
112
+ conn.close()
113
+
114
+ out = []
115
+ for r in rows:
116
+ session_id = r[6] if has_session and len(r) > 6 else None
117
+ out.append(CandidateTurn(
118
+ turn_key=r[0],
119
+ chat_id=str(r[1]) if r[1] is not None else None,
120
+ thread_id=str(r[2]) if r[2] is not None else None,
121
+ started_at=int(r[3]) if r[3] is not None else None,
122
+ ended_at=int(r[4]) if r[4] is not None else None,
123
+ ended_via=r[5],
124
+ session_id=session_id or None,
125
+ ))
126
+ return out
127
+
128
+
129
+ # --------------------------------------------------------------------------- #
130
+ # Transcript event spans (for the containment join — must-fix #3).
131
+ # --------------------------------------------------------------------------- #
132
+ def _ts_to_ms(value) -> Optional[int]:
133
+ """Coerce a transcript entry timestamp to epoch milliseconds.
134
+
135
+ Accepts an int/float (already epoch ms, or epoch seconds if small), or an
136
+ ISO-8601 string (``2026-07-11T01:39:35.707Z`` / with offset). Returns None
137
+ when unparseable.
138
+ """
139
+ if value is None:
140
+ return None
141
+ if isinstance(value, (int, float)):
142
+ v = float(value)
143
+ # Heuristic: < 1e12 ⇒ seconds, else milliseconds.
144
+ return int(v * 1000) if v < 1e12 else int(v)
145
+ if isinstance(value, str):
146
+ s = value.strip()
147
+ if not s:
148
+ return None
149
+ try:
150
+ iso = s.replace("Z", "+00:00")
151
+ dt = datetime.fromisoformat(iso)
152
+ if dt.tzinfo is None:
153
+ dt = dt.replace(tzinfo=timezone.utc)
154
+ return int(dt.timestamp() * 1000)
155
+ except ValueError:
156
+ try:
157
+ return int(float(s))
158
+ except ValueError:
159
+ return None
160
+ return None
161
+
162
+
163
+ @dataclass
164
+ class TranscriptSpan:
165
+ path: str
166
+ session_id: str
167
+ first_ms: Optional[int]
168
+ last_ms: Optional[int]
169
+ chat_ids: frozenset # chat ids observed in the transcript (may be empty)
170
+
171
+
172
+ def transcript_span(path: str) -> TranscriptSpan:
173
+ """Compute a transcript's event time-span + any observed chat ids.
174
+
175
+ Reads the raw ``.jsonl`` line-by-line, extracting each entry's ``timestamp``
176
+ (Claude Code stamps one per line). ``chat_ids`` collects any ``chat_id`` seen
177
+ (nested under ``message.metadata`` in some formats, or top-level in test
178
+ fixtures) so the join can filter by chat/thread when the data carries it —
179
+ absence never forces a mismatch, it just widens to span-only.
180
+ """
181
+ session_id = os.path.splitext(os.path.basename(path))[0]
182
+ first_ms: Optional[int] = None
183
+ last_ms: Optional[int] = None
184
+ chat_ids: set = set()
185
+ try:
186
+ with open(path, encoding="utf-8") as f:
187
+ for line in f:
188
+ line = line.strip()
189
+ if not line:
190
+ continue
191
+ try:
192
+ entry = json.loads(line)
193
+ except json.JSONDecodeError:
194
+ continue
195
+ if not isinstance(entry, dict):
196
+ continue
197
+ ms = _ts_to_ms(entry.get("timestamp"))
198
+ if ms is not None:
199
+ if first_ms is None or ms < first_ms:
200
+ first_ms = ms
201
+ if last_ms is None or ms > last_ms:
202
+ last_ms = ms
203
+ cid = entry.get("chat_id")
204
+ if cid is None:
205
+ msg = entry.get("message")
206
+ if isinstance(msg, dict):
207
+ meta = msg.get("metadata")
208
+ if isinstance(meta, dict):
209
+ cid = meta.get("chat_id")
210
+ if cid is not None:
211
+ chat_ids.add(str(cid))
212
+ except OSError:
213
+ pass
214
+ return TranscriptSpan(
215
+ path=path, session_id=session_id,
216
+ first_ms=first_ms, last_ms=last_ms, chat_ids=frozenset(chat_ids),
217
+ )
218
+
219
+
220
+ # --------------------------------------------------------------------------- #
221
+ # The containment join (must-fix #3).
222
+ # --------------------------------------------------------------------------- #
223
+ @dataclass
224
+ class JoinResult:
225
+ kind: str # "direct" | "span" | "ambiguous" | "unmatched"
226
+ session_id: Optional[str]
227
+ transcript_path: Optional[str]
228
+ detail: str = ""
229
+
230
+
231
+ def resolve_transcript_for_turn(
232
+ turn: CandidateTurn,
233
+ spans: list,
234
+ *,
235
+ slack_ms: int = 0,
236
+ require_direct_sessionid: bool = False,
237
+ ) -> JoinResult:
238
+ """Resolve a candidate turn to its transcript.
239
+
240
+ * If the registry row carries a stamped ``session_id`` (migrated schema) and
241
+ a transcript with that stem exists, that is a ``direct`` join.
242
+ * Otherwise (all current historical rows), use event-span CONTAINMENT: the
243
+ transcript whose ``[first_ms, last_ms]`` (widened by ``slack_ms``) CONTAINS
244
+ the turn's ``started_at``, filtered by chat id when the transcript carries
245
+ one. Exactly one match ⇒ ``span`` join; zero ⇒ ``unmatched``; more than one
246
+ ⇒ ``ambiguous`` (REFUSED, never guessed).
247
+
248
+ ``require_direct_sessionid=True`` refuses any row without a stamped
249
+ ``session_id`` rather than fall back to the span join (strict, going-forward
250
+ only mode).
251
+ """
252
+ if turn.session_id:
253
+ for sp in spans:
254
+ if sp.session_id == turn.session_id:
255
+ return JoinResult("direct", sp.session_id, sp.path,
256
+ "stamped session_id")
257
+ return JoinResult("unmatched", turn.session_id, None,
258
+ "stamped session_id has no surviving transcript")
259
+
260
+ if require_direct_sessionid:
261
+ return JoinResult("ambiguous", None, None,
262
+ "no stamped session_id and --require-direct-sessionid set")
263
+
264
+ if turn.started_at is None:
265
+ return JoinResult("unmatched", None, None, "turn has no started_at")
266
+
267
+ matches = []
268
+ for sp in spans:
269
+ if sp.first_ms is None or sp.last_ms is None:
270
+ continue
271
+ lo = sp.first_ms - slack_ms
272
+ hi = sp.last_ms + slack_ms
273
+ if not (lo <= turn.started_at <= hi):
274
+ continue
275
+ # Chat filter: only apply when BOTH sides carry a chat id.
276
+ if turn.chat_id and sp.chat_ids and turn.chat_id not in sp.chat_ids:
277
+ continue
278
+ matches.append(sp)
279
+
280
+ if len(matches) == 1:
281
+ return JoinResult("span", matches[0].session_id, matches[0].path,
282
+ "unique span containment")
283
+ if not matches:
284
+ return JoinResult("unmatched", None, None,
285
+ "no transcript span contains the turn timestamp")
286
+ return JoinResult(
287
+ "ambiguous", None, None,
288
+ f"{len(matches)} transcripts contain the turn timestamp; refusing to guess",
289
+ )
290
+
291
+
292
+ # --------------------------------------------------------------------------- #
293
+ # Correct-bank targeting from switchroom.yaml (+ settings cross-check).
294
+ # --------------------------------------------------------------------------- #
295
+ def _load_agent_collections(yaml_path: str) -> Optional[dict]:
296
+ """Return ``{agent_name: collection_or_None, ...}`` from switchroom.yaml, or
297
+ None if the file can't be parsed. Prefers PyYAML; falls back to a minimal
298
+ scanner for the ``agents: -> <name>: -> memory: -> collection:`` shape when
299
+ PyYAML is unavailable."""
300
+ try:
301
+ with open(yaml_path, encoding="utf-8") as f:
302
+ text = f.read()
303
+ except OSError:
304
+ return None
305
+ try:
306
+ import yaml # type: ignore
307
+ data = yaml.safe_load(text)
308
+ agents = (data or {}).get("agents")
309
+ if isinstance(agents, dict):
310
+ out = {}
311
+ for name, cfg in agents.items():
312
+ coll = None
313
+ if isinstance(cfg, dict):
314
+ mem = cfg.get("memory")
315
+ if isinstance(mem, dict):
316
+ coll = mem.get("collection")
317
+ out[str(name)] = coll
318
+ return out
319
+ except Exception:
320
+ pass
321
+ return _scan_agent_collections(text)
322
+
323
+
324
+ def _scan_agent_collections(text: str) -> dict:
325
+ """Dependency-free minimal parser for the specific nested shape we need.
326
+
327
+ Tracks indentation to find each ``<name>:`` directly under ``agents:`` and a
328
+ ``collection:`` under that agent's ``memory:``. Good enough for bank
329
+ resolution; PyYAML is used when present.
330
+ """
331
+ out: dict = {}
332
+ lines = text.splitlines()
333
+ in_agents = False
334
+ agents_indent = None
335
+ cur_agent = None
336
+ cur_agent_indent = None
337
+ in_memory = False
338
+ memory_indent = None
339
+ for raw in lines:
340
+ if not raw.strip() or raw.lstrip().startswith("#"):
341
+ continue
342
+ indent = len(raw) - len(raw.lstrip(" "))
343
+ stripped = raw.strip()
344
+ if not in_agents:
345
+ if stripped.rstrip() == "agents:":
346
+ in_agents = True
347
+ agents_indent = indent
348
+ continue
349
+ # Left the agents block?
350
+ if indent <= agents_indent and stripped.rstrip() != "agents:":
351
+ break
352
+ # A new agent key: one indent level deeper than "agents:".
353
+ if cur_agent_indent is None or indent == cur_agent_indent:
354
+ if stripped.endswith(":") and ":" not in stripped[:-1]:
355
+ cur_agent = stripped[:-1].strip().strip('"\'')
356
+ cur_agent_indent = indent
357
+ out.setdefault(cur_agent, None)
358
+ in_memory = False
359
+ memory_indent = None
360
+ continue
361
+ if cur_agent is None:
362
+ continue
363
+ if indent <= cur_agent_indent:
364
+ # Dedented back to (or above) agent-key level → possibly a new agent.
365
+ if stripped.endswith(":") and ":" not in stripped[:-1]:
366
+ cur_agent = stripped[:-1].strip().strip('"\'')
367
+ cur_agent_indent = indent
368
+ out.setdefault(cur_agent, None)
369
+ in_memory = False
370
+ memory_indent = None
371
+ continue
372
+ if stripped.rstrip() == "memory:":
373
+ in_memory = True
374
+ memory_indent = indent
375
+ continue
376
+ if in_memory and indent > memory_indent and stripped.startswith("collection:"):
377
+ val = stripped.split(":", 1)[1].strip().strip('"\'')
378
+ out[cur_agent] = val or None
379
+ in_memory = False
380
+ return out
381
+
382
+
383
+ def _settings_bank_id(settings_path: str) -> Optional[str]:
384
+ """Read the ``X-Bank-Id`` header from an agent's ``.claude/settings.json``.
385
+
386
+ Searches any ``headers`` map (e.g. under an MCP/hindsight server env) for an
387
+ ``X-Bank-Id`` key. Returns None when absent/unparseable."""
388
+ try:
389
+ with open(settings_path, encoding="utf-8") as f:
390
+ data = json.load(f)
391
+ except (OSError, ValueError):
392
+ return None
393
+
394
+ found: list = []
395
+
396
+ def _walk(obj):
397
+ if isinstance(obj, dict):
398
+ for k, v in obj.items():
399
+ if isinstance(k, str) and k.lower() == "x-bank-id" and isinstance(v, str):
400
+ found.append(v)
401
+ else:
402
+ _walk(v)
403
+ elif isinstance(obj, list):
404
+ for it in obj:
405
+ _walk(it)
406
+
407
+ _walk(data)
408
+ return found[0] if found else None
409
+
410
+
411
+ @dataclass
412
+ class BankResolution:
413
+ ok: bool
414
+ bank_id: Optional[str]
415
+ reason: str
416
+
417
+
418
+ def resolve_true_bank(
419
+ agent: str,
420
+ *,
421
+ switchroom_yaml_path: str,
422
+ settings_path: Optional[str] = None,
423
+ ) -> BankResolution:
424
+ """Resolve ``agent``'s TRUE bank, or REFUSE.
425
+
426
+ Bank = ``switchroom.yaml agents.<agent>.memory.collection ?? <agent>``,
427
+ cross-checked against the agent's ``.claude/settings.json`` ``X-Bank-Id``.
428
+ Refuses (``ok=False``, no bank) when: the yaml can't be read; the agent has
429
+ no row in it; or the yaml-derived bank and a present ``X-Bank-Id`` disagree.
430
+ A missing ``X-Bank-Id`` is NOT a refusal (the header is not always present) —
431
+ only a present-and-disagreeing one is.
432
+ """
433
+ collections = _load_agent_collections(switchroom_yaml_path)
434
+ if collections is None:
435
+ return BankResolution(False, None,
436
+ f"could not read switchroom.yaml at {switchroom_yaml_path}")
437
+ if agent not in collections:
438
+ return BankResolution(False, None,
439
+ f"{agent} has no row in switchroom.yaml — refusing to guess a bank")
440
+ bank = collections.get(agent) or agent
441
+
442
+ if settings_path and os.path.isfile(settings_path):
443
+ header_bank = _settings_bank_id(settings_path)
444
+ if header_bank and header_bank != bank:
445
+ return BankResolution(
446
+ False, None,
447
+ f"switchroom.yaml resolves {agent} to bank '{bank}' but "
448
+ f".claude/settings.json X-Bank-Id is '{header_bank}' — refusing on disagreement",
449
+ )
450
+ return BankResolution(True, bank, "resolved from switchroom.yaml memory.collection")