switchroom 0.18.24 → 0.18.26

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 (54) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +2 -2
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1827 -831
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +314 -3
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/render/code-segments.ts +210 -0
  15. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  16. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  17. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  18. package/telegram-plugin/render/line-start-guard.ts +167 -0
  19. package/telegram-plugin/render/rich-render.ts +7 -0
  20. package/telegram-plugin/rich-send.ts +48 -2
  21. package/telegram-plugin/session-tail.ts +185 -0
  22. package/telegram-plugin/subagent-watcher.ts +45 -0
  23. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  24. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  25. package/telegram-plugin/tests/history.test.ts +91 -0
  26. package/telegram-plugin/tests/model-command.test.ts +189 -12
  27. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  28. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  29. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  30. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  31. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  32. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  33. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  35. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  36. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  37. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  38. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  39. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  40. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  41. package/telegram-plugin/tool-activity-summary.ts +54 -3
  42. package/telegram-plugin/worker-activity-feed.ts +104 -0
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  44. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  45. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  46. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  47. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  48. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  49. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  50. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  51. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  54. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env python3
2
+ """Boot reconciliation of un-committed transcript tails (switchroom #3244 §1.3).
3
+
4
+ The load-bearing guarantee. The `.jsonl` transcript survives *every* kind of
5
+ session death (SIGKILL / OOM / watchdog / SIGTERM) because Claude Code writes it
6
+ independent of any hook. This module runs at SessionStart, AFTER the
7
+ pending-retains drain, and diffs the durable per-session watermark
8
+ (``lib/watermark``) against the on-disk transcript tail: any human turns after
9
+ the watermark that were never committed are retained here, BEFORE the new
10
+ session starts — so the next ``recall``/``reflect`` sees work an abrupt kill
11
+ would otherwise have silently dropped.
12
+
13
+ Properties (design §1.3):
14
+
15
+ * **Deterministic + idempotent.** Each slice is posted under the content-derived
16
+ ``document_id`` (``retain.slice_document_id``), so running reconcile twice over
17
+ an unchanged transcript upserts the identical documents. The watermark is
18
+ advanced only after a **confirmed** (``async_processing=False``) 200, so a
19
+ crash mid-reconcile re-does the same slice next boot, never skips it.
20
+ * **Bounded, but NEVER silently truncating.** Three bounds — a lookback window
21
+ (``HINDSIGHT_RECONCILE_LOOKBACK_H``, 48h), a per-session turn cap
22
+ (``HINDSIGHT_RECONCILE_MAX_TURNS``, 200), and a wall-clock budget
23
+ (``HINDSIGHT_RECONCILE_BUDGET_S``, 4s) — keep boot cheap, but every bound
24
+ ENQUEUES the excluded remainder to ``pending-retains/`` (drained next boot)
25
+ rather than dropping it. A >48h idle-then-killed session, a >200-turn loss, or
26
+ an over-budget boot is recovered across one or more boots, not forgotten.
27
+ * **Shared pacing.** Every inline POST goes through the shared
28
+ ``retain-inflight.lock`` (§1.5) so boot reconcile can never storm the daemon
29
+ alongside a live retain or a running backfill.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import glob
35
+ import os
36
+ import sys
37
+ import time
38
+
39
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
+
41
+ from lib import watermark
42
+ from lib.bank import derive_bank_id
43
+ from lib.client import HindsightClient
44
+ from lib.config import debug_log, load_config
45
+ from lib.content import _is_tool_result_only_user_message, slice_last_turns_by_user_boundary
46
+ from lib.daemon import get_api_url
47
+ from lib.pacing import inflight_lock
48
+ from lib.pending import enqueue as pending_enqueue
49
+ from retain import build_retain_payload, read_transcript
50
+
51
+
52
+ def _lookback_seconds() -> float:
53
+ try:
54
+ return max(0.0, float(os.environ.get("HINDSIGHT_RECONCILE_LOOKBACK_H", "48"))) * 3600.0
55
+ except ValueError:
56
+ return 48 * 3600.0
57
+
58
+
59
+ def _max_turns() -> int:
60
+ try:
61
+ return max(1, int(os.environ.get("HINDSIGHT_RECONCILE_MAX_TURNS", "200")))
62
+ except ValueError:
63
+ return 200
64
+
65
+
66
+ def _budget_seconds() -> float:
67
+ try:
68
+ return max(0.5, float(os.environ.get("HINDSIGHT_RECONCILE_BUDGET_S", "4")))
69
+ except ValueError:
70
+ return 4.0
71
+
72
+
73
+ def _enabled(config: dict) -> bool:
74
+ env = os.environ.get("HINDSIGHT_RECONCILE_ON_START")
75
+ if env is not None:
76
+ return env.strip().lower() in ("1", "true", "yes")
77
+ # Config flag (scaffold: memory.retain.reconcile_on_start), default on.
78
+ return bool(config.get("reconcileOnStart", True))
79
+
80
+
81
+ def _transcripts_dir(hook_input: dict | None) -> str | None:
82
+ """Resolve the directory holding this agent's session ``.jsonl`` files.
83
+
84
+ Override with ``HINDSIGHT_TRANSCRIPTS_DIR`` (tests / explicit). Otherwise
85
+ derive the Claude ``projects/<project>/`` directory from the boot hook's
86
+ ``transcript_path``. Returns ``None`` when it can't be located (reconcile
87
+ then no-ops — never a crash).
88
+ """
89
+ override = os.environ.get("HINDSIGHT_TRANSCRIPTS_DIR")
90
+ if override:
91
+ return override
92
+ tpath = (hook_input or {}).get("transcript_path") or ""
93
+ if tpath:
94
+ d = os.path.dirname(os.path.abspath(tpath))
95
+ if os.path.isdir(d):
96
+ return d
97
+ return None
98
+
99
+
100
+ def _human_turns(messages: list) -> int:
101
+ return sum(
102
+ 1
103
+ for m in messages
104
+ if isinstance(m, dict) and m.get("role") == "user" and not _is_tool_result_only_user_message(m)
105
+ )
106
+
107
+
108
+ def _tail_after(messages: list, last_uuid: str | None) -> list:
109
+ """Return the transcript entries AFTER the watermark anchor.
110
+
111
+ No watermark, or a stale anchor compaction removed → the whole transcript is
112
+ the gap (a safe re-upsert, never a skip).
113
+ """
114
+ if not last_uuid:
115
+ return list(messages)
116
+ for i, m in enumerate(messages):
117
+ if isinstance(m, dict) and m.get("uuid") == last_uuid:
118
+ return messages[i + 1:]
119
+ return list(messages)
120
+
121
+
122
+ def _session_id_from_path(path: str) -> str:
123
+ return os.path.splitext(os.path.basename(path))[0]
124
+
125
+
126
+ def reconcile(config: dict | None = None, hook_input: dict | None = None) -> dict:
127
+ """Diff watermarks vs transcript tails and recover un-committed work.
128
+
129
+ Returns a summary dict. Never raises — boot must not be broken by reconcile.
130
+ """
131
+ config = config or load_config()
132
+ summary = {
133
+ "scanned": 0,
134
+ "reconciled": 0, # transcripts with an inline-posted gap
135
+ "posts_ok": 0,
136
+ "enqueued": 0, # slices deferred to pending-retains (bounds/failure)
137
+ "skipped_clean": 0,
138
+ "disabled": False,
139
+ }
140
+ if not config.get("autoRetain"):
141
+ summary["disabled"] = True
142
+ return summary
143
+ if not _enabled(config):
144
+ summary["disabled"] = True
145
+ return summary
146
+
147
+ tdir = _transcripts_dir(hook_input)
148
+ if not tdir:
149
+ debug_log(config, "reconcile_tail: no transcripts dir resolved, skipping")
150
+ return summary
151
+
152
+ # Resolve the daemon WITHOUT starting it — session_start only calls us when
153
+ # the health check already passed. If it's unreachable, skip: the transcript
154
+ # stays on disk and the next boot reconciles (no loss).
155
+ def _dbg(*a):
156
+ debug_log(config, *a)
157
+
158
+ try:
159
+ api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
160
+ client = HindsightClient(
161
+ api_url,
162
+ config.get("hindsightApiToken"),
163
+ request_timeout_override=config.get("requestTimeoutSeconds"),
164
+ )
165
+ except (RuntimeError, ValueError) as e:
166
+ debug_log(config, f"reconcile_tail: daemon unavailable, skipping: {e}")
167
+ return summary
168
+
169
+ api_token = config.get("hindsightApiToken")
170
+ lookback = _lookback_seconds()
171
+ max_turns = _max_turns()
172
+ budget = _budget_seconds()
173
+ started = time.monotonic()
174
+ now = time.time()
175
+
176
+ transcripts = sorted(
177
+ glob.glob(os.path.join(tdir, "**", "*.jsonl"), recursive=True)
178
+ + glob.glob(os.path.join(tdir, "*.jsonl"))
179
+ )
180
+ # De-dup (the two globs can overlap) while keeping order.
181
+ seen = set()
182
+ transcripts = [p for p in transcripts if not (p in seen or seen.add(p))]
183
+
184
+ for path in transcripts:
185
+ summary["scanned"] += 1
186
+ session_id = _session_id_from_path(path)
187
+ try:
188
+ mtime = os.path.getmtime(path)
189
+ except OSError:
190
+ continue
191
+ within_lookback = (now - mtime) <= lookback
192
+
193
+ messages = read_transcript(path)
194
+ if not messages:
195
+ continue
196
+ wm = watermark.load(session_id)
197
+ last_uuid = wm.get("last_uuid") if wm else None
198
+ tail = _tail_after(messages, last_uuid)
199
+ if _human_turns(tail) == 0:
200
+ summary["skipped_clean"] += 1
201
+ continue
202
+
203
+ synthetic_hook = {"session_id": session_id, "cwd": (hook_input or {}).get("cwd", "")}
204
+ bank_id = derive_bank_id(synthetic_hook, config)
205
+
206
+ # Out-of-lookback but with a real un-committed tail → do NOT drop it
207
+ # (design §1.3 tier ii): enqueue for a later boot, don't process inline.
208
+ # Over-budget → same treatment.
209
+ if not within_lookback or (time.monotonic() - started) > budget:
210
+ if _enqueue_slice(config, session_id, path, messages, tail, bank_id, api_url, api_token):
211
+ summary["enqueued"] += 1
212
+ continue
213
+
214
+ # Turn-cap split: process the most-recent MAX_TURNS human turns inline;
215
+ # defer the OLDER remainder (never truncate).
216
+ recent = slice_last_turns_by_user_boundary(tail, max_turns)
217
+ did_split = len(recent) < len(tail)
218
+ if did_split and _human_turns(tail[: len(tail) - len(recent)]) > 0:
219
+ older = tail[: len(tail) - len(recent)]
220
+ if _enqueue_slice(config, session_id, path, messages, older, bank_id, api_url, api_token):
221
+ summary["enqueued"] += 1
222
+
223
+ # CRITICAL (switchroom #3244 F1/F2): when we split, the older remainder
224
+ # (which precedes `recent` in the transcript) is only DEFERRED — either
225
+ # enqueued for a later sync drain, or, if the enqueue failed, not stored
226
+ # at all. Either way it is NOT yet confirmed-persisted. The watermark is
227
+ # a single "last contiguously-committed entry" pointer, so advancing it
228
+ # to `recent`'s end (the transcript tail) would jump PAST the unconfirmed
229
+ # older remainder — and `_tail_after` would then never re-derive it,
230
+ # silently losing it if the drain drops (async) or the enqueue failed.
231
+ # So on a split we do NOT advance the watermark: the recent slice is
232
+ # upserted for immediate recall, but the anchor stays put and the next
233
+ # boot re-derives (and idempotently re-upserts) the whole tail until it
234
+ # fits the budget and is confirmed end-to-end. Redundant work in the
235
+ # rare >MAX_TURNS-loss case; never loss.
236
+ posted = _post_inline(
237
+ config, client, session_id, path, messages, recent, bank_id, api_url, api_token,
238
+ advance_watermark=not did_split,
239
+ )
240
+ if posted == "ok":
241
+ summary["reconciled"] += 1
242
+ summary["posts_ok"] += 1
243
+ elif posted == "enqueued":
244
+ summary["enqueued"] += 1
245
+
246
+ debug_log(config, f"reconcile_tail summary: {summary}")
247
+ return summary
248
+
249
+
250
+ def _post_inline(
251
+ config, client, session_id, path, all_messages, slice_messages, bank_id, api_url, api_token,
252
+ advance_watermark: bool = True,
253
+ ) -> str:
254
+ """POST one gap slice inline, advancing the watermark on confirmed persistence.
255
+
256
+ Returns "ok", "enqueued" (POST failed / lock busy → deferred), or "skip".
257
+
258
+ ``advance_watermark=False`` (turn-cap split, #3244 F1/F2): the slice is
259
+ upserted for immediate recall but the watermark is NOT moved, because an
260
+ unconfirmed older remainder precedes this slice — advancing would jump past
261
+ it and risk silent loss. The next boot re-derives and idempotently
262
+ re-upserts.
263
+ """
264
+ built = build_retain_payload(
265
+ config,
266
+ session_id,
267
+ slice_messages,
268
+ all_messages,
269
+ bank_id=bank_id,
270
+ api_url=api_url,
271
+ api_token=api_token,
272
+ retain_full_window=True,
273
+ document_id=None, # deterministic content-derived id (§1)
274
+ )
275
+ if built is None:
276
+ return "skip"
277
+ payload = built["payload"]
278
+ document_id = built["document_id"]
279
+
280
+ with inflight_lock(blocking=True) as acquired:
281
+ if not acquired: # pragma: no cover - blocking acquire fails open
282
+ pending_enqueue(payload, RuntimeError("reconcile lock unavailable"))
283
+ return "enqueued"
284
+ try:
285
+ client.retain(
286
+ bank_id=bank_id,
287
+ content=payload["content"],
288
+ document_id=document_id,
289
+ context=payload["context"],
290
+ metadata=payload["metadata"],
291
+ tags=payload["tags"],
292
+ timeout=15,
293
+ async_processing=False,
294
+ )
295
+ except Exception as e:
296
+ debug_log(config, f"reconcile_tail: inline POST failed, enqueuing: {e}")
297
+ pending_enqueue(payload, e)
298
+ return "enqueued"
299
+
300
+ if not advance_watermark:
301
+ return "ok"
302
+
303
+ try:
304
+ watermark.commit(
305
+ session_id,
306
+ built["last_uuid"],
307
+ document_id,
308
+ transcript_path=path,
309
+ ordered_uuids=built["ordered_uuids"],
310
+ )
311
+ except Exception as e: # pragma: no cover - defensive
312
+ debug_log(config, f"reconcile_tail: watermark commit skipped: {e}")
313
+ return "ok"
314
+
315
+
316
+ def _enqueue_slice(config, session_id, path, all_messages, slice_messages, bank_id, api_url, api_token) -> bool:
317
+ """Build a payload for a slice and enqueue it to pending-retains (no POST)."""
318
+ built = build_retain_payload(
319
+ config,
320
+ session_id,
321
+ slice_messages,
322
+ all_messages,
323
+ bank_id=bank_id,
324
+ api_url=api_url,
325
+ api_token=api_token,
326
+ retain_full_window=True,
327
+ document_id=None,
328
+ )
329
+ if built is None:
330
+ return False
331
+ queued = pending_enqueue(built["payload"], RuntimeError("reconcile deferred (bound/budget)"))
332
+ if queued is None:
333
+ debug_log(config, "reconcile_tail: pending-retains full, could not enqueue remainder")
334
+ return False
335
+ return True
336
+
337
+
338
+ if __name__ == "__main__":
339
+ try:
340
+ s = reconcile()
341
+ print(f"[Hindsight] reconcile_tail: {s}", file=sys.stderr)
342
+ except Exception as e:
343
+ print(f"[Hindsight] reconcile_tail unexpected error: {e}", file=sys.stderr)
344
+ sys.exit(0)