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.
- package/dist/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1827 -831
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +314 -3
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +104 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- 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)
|