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.
- package/bin/handoff-briefing.sh +15 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -73
- 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 +2712 -2219
- package/dist/host-control/main.js +110 -70
- package/dist/vault/approvals/kernel-server.js +116 -70
- package/dist/vault/broker/server.js +314 -202
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +105 -34
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +1128 -666
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +656 -388
- package/telegram-plugin/gateway/model-command.ts +331 -602
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/history.ts +153 -23
- 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 +99 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -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/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +135 -0
- package/telegram-plugin/tests/model-command.test.ts +427 -1512
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- 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 +93 -7
- 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 +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
|
|
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
|
-
|
|
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
|
|
327
|
+
"""Format the current time in the agent's LOCAL timezone for recall context.
|
|
259
328
|
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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")
|