switchroom 0.18.29 → 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.
- package/bin/handoff-briefing.sh +8 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -16
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2074 -1585
- package/dist/host-control/main.js +110 -13
- package/dist/vault/approvals/kernel-server.js +116 -13
- package/dist/vault/broker/server.js +314 -145
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +73 -20
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +560 -96
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/gateway.ts +212 -17
- package/telegram-plugin/gateway/model-command.ts +104 -0
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +43 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +68 -1
- package/telegram-plugin/tests/model-command.test.ts +133 -0
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- package/vendor/hindsight-memory/tests/test_content.py +35 -0
|
@@ -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")
|