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.
Files changed (38) hide show
  1. package/bin/handoff-briefing.sh +8 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -16
  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 +2074 -1585
  9. package/dist/host-control/main.js +110 -13
  10. package/dist/vault/approvals/kernel-server.js +116 -13
  11. package/dist/vault/broker/server.js +314 -145
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +73 -20
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +560 -96
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/gateway.ts +212 -17
  18. package/telegram-plugin/gateway/model-command.ts +104 -0
  19. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  20. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  21. package/telegram-plugin/llm-error-present.ts +24 -0
  22. package/telegram-plugin/model-unavailable.ts +55 -0
  23. package/telegram-plugin/operator-events.ts +113 -0
  24. package/telegram-plugin/pending-user-notice.ts +88 -0
  25. package/telegram-plugin/shared/local-time.ts +43 -0
  26. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  27. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  28. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
  29. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  30. package/telegram-plugin/tests/local-time.test.ts +68 -1
  31. package/telegram-plugin/tests/model-command.test.ts +133 -0
  32. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  33. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  34. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  35. package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
  36. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  38. 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")