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
@@ -72,7 +72,18 @@ def _budget_seconds() -> float:
72
72
 
73
73
 
74
74
  def _retry_one(entry: dict, timeout: int) -> None:
75
- """POST a single queued retain. Raises on failure."""
75
+ """POST a single queued retain. Raises on failure.
76
+
77
+ Posts ``async_processing=False`` (commit-before-ack, switchroom #3244 §1.1):
78
+ the drain is a DURABILITY path — it deletes the pending entry on a 200, so
79
+ the 200 must prove durable persistence, not merely ack-of-receipt. A bare
80
+ async 200 followed by a dropped extraction would delete the queue entry
81
+ while the content never lands, and (for boot-reconcile remainders whose
82
+ watermark already advanced) there is no reconcile backstop — silent loss
83
+ (the #3244 bug). All drained entries — Stop-hook A2 failures, SessionEnd
84
+ failures, and reconcile-remainder deferrals — are durability retries, so
85
+ sync is correct for every one.
86
+ """
76
87
  client = HindsightClient(entry["api_url"], entry.get("api_token"))
77
88
  client.retain(
78
89
  bank_id=entry["bank_id"],
@@ -82,6 +93,7 @@ def _retry_one(entry: dict, timeout: int) -> None:
82
93
  metadata=entry.get("metadata") or {},
83
94
  tags=entry.get("tags"),
84
95
  timeout=timeout,
96
+ async_processing=False,
85
97
  )
86
98
 
87
99
 
@@ -173,12 +173,22 @@ class HindsightClient:
173
173
  metadata: Optional[dict] = None,
174
174
  tags: Optional[list] = None,
175
175
  timeout: int = 15,
176
+ async_processing: bool = True,
176
177
  ) -> dict:
177
178
  """Retain content into a bank's memory.
178
179
 
179
- Posts with async=true so the server processes in the background.
180
- The context field helps Hindsight cluster memories by provenance
181
- (e.g. "claude-code" vs manual retains).
180
+ By default posts with ``async=true`` so the server processes extraction
181
+ in the background (a 200 is an ack-of-receipt, not proof of durable
182
+ persistence). The context field helps Hindsight cluster memories by
183
+ provenance (e.g. "claude-code" vs manual retains).
184
+
185
+ ``async_processing=False`` (switchroom #3244 §1.1) posts ``async=false``
186
+ so the 200 is returned only after the daemon has durably committed the
187
+ item — commit-before-ack. Durability paths whose success advances the
188
+ retain watermark (the Stop-hook durability retain and boot
189
+ reconciliation) MUST use this so a bare async 200 can never falsely mark
190
+ unpersisted work as committed. (Merge precondition: the daemon honours
191
+ ``async=false`` as commit-before-ack — verified by the §1.1 probe.)
182
192
  """
183
193
  path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
184
194
  item = {
@@ -192,7 +202,7 @@ class HindsightClient:
192
202
  item["tags"] = tags
193
203
  body = {
194
204
  "items": [item],
195
- "async": True,
205
+ "async": bool(async_processing),
196
206
  }
197
207
  return self._request("POST", path, body, timeout=timeout)
198
208
 
@@ -95,6 +95,12 @@ DEFAULTS = {
95
95
  "retainContext": "claude-code",
96
96
  "retainTags": [],
97
97
  "retainMetadata": {},
98
+ # Switchroom #3244 — boot reconciliation of un-committed transcript tails.
99
+ # On by default; the load-bearing recovery for work an abrupt session death
100
+ # (SIGKILL/OOM/watchdog) skipped. Disable per-agent via
101
+ # HINDSIGHT_RECONCILE_ON_START=false. reconcile_tail.py also honours the
102
+ # HINDSIGHT_RECONCILE_{LOOKBACK_H,MAX_TURNS,BUDGET_S} bounds (read directly).
103
+ "reconcileOnStart": True,
98
104
  "recallAdditionalBanks": [],
99
105
  # Connection
100
106
  "hindsightApiUrl": None,
@@ -134,6 +140,8 @@ ENV_OVERRIDES = {
134
140
  "HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
135
141
  "HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
136
142
  "HINDSIGHT_RETAIN_MODE": ("retainMode", str),
143
+ # Switchroom #3244 — boot reconciliation on/off (default on).
144
+ "HINDSIGHT_RECONCILE_ON_START": ("reconcileOnStart", bool),
137
145
  "HINDSIGHT_RECALL_BUDGET": ("recallBudget", str),
138
146
  "HINDSIGHT_RECALL_MAX_TOKENS": ("recallMaxTokens", int),
139
147
  # Switchroom-local: count cap. Set by start.sh from
@@ -0,0 +1,102 @@
1
+ """Shared fleet pacing lock for Hindsight retain POSTs (switchroom #3244 §1.5).
2
+
3
+ A single per-agent advisory flock, ``$HOME/.hindsight/retain-inflight.lock``,
4
+ serialises the durability-path retain POSTs so at most ONE is in flight for an
5
+ agent at a time — covering the live Stop-hook retain, boot reconciliation, and
6
+ (PR2) the one-time backfill collectively. This is the "never storm the daemon"
7
+ guard.
8
+
9
+ Usage::
10
+
11
+ from lib.pacing import inflight_lock
12
+
13
+ with inflight_lock(blocking=False) as acquired:
14
+ if not acquired:
15
+ ... # someone else holds it — enqueue and return, do NOT wait
16
+ client.retain(...)
17
+
18
+ * Boot reconcile / backfill acquire it **blocking** (they are batch work and
19
+ should wait their turn).
20
+ * The live Stop hook acquires it **non-blocking** (``blocking=False``): if a
21
+ reconcile/backfill holds it the live retain must NOT wait (turn latency must
22
+ not regress) — the caller enqueues its payload to ``pending-retains/`` and
23
+ returns, and the next boot drain delivers it.
24
+
25
+ The context manager yields ``True`` when the lock was acquired (and releases it
26
+ on exit) and ``False`` when a non-blocking acquire failed (nothing to release).
27
+ It never raises on lock-infrastructure errors: if flock is unavailable it
28
+ fails OPEN (yields ``True``) so durability is never blocked by the lock itself.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import os
35
+ import sys
36
+ from typing import Iterator
37
+
38
+ if sys.platform != "win32":
39
+ import fcntl
40
+ else: # pragma: no cover - switchroom agents are Linux only
41
+ fcntl = None
42
+
43
+
44
+ def lock_path() -> str:
45
+ """Path of the shared inflight lock. Override with ``HINDSIGHT_INFLIGHT_LOCK``."""
46
+ override = os.environ.get("HINDSIGHT_INFLIGHT_LOCK")
47
+ if override:
48
+ return override
49
+ return os.path.join(os.path.expanduser("~"), ".hindsight", "retain-inflight.lock")
50
+
51
+
52
+ @contextlib.contextmanager
53
+ def inflight_lock(blocking: bool = True) -> Iterator[bool]:
54
+ """Acquire the shared inflight lock.
55
+
56
+ Yields ``True`` if acquired (released on exit), ``False`` if a non-blocking
57
+ acquire could not get it. Fails OPEN (yields ``True``, no real lock) when
58
+ flock is unavailable so the durability path is never blocked by lock
59
+ infrastructure.
60
+ """
61
+ if fcntl is None:
62
+ yield True
63
+ return
64
+
65
+ path = lock_path()
66
+ d = os.path.dirname(path)
67
+ try:
68
+ if d and not os.path.isdir(d):
69
+ os.makedirs(d, mode=0o700, exist_ok=True)
70
+ except OSError:
71
+ # Can't even create the dir — fail open rather than block durability.
72
+ yield True
73
+ return
74
+
75
+ lock_fd = None
76
+ acquired = False
77
+ try:
78
+ lock_fd = open(path, "w")
79
+ flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
80
+ try:
81
+ fcntl.flock(lock_fd, flags)
82
+ acquired = True
83
+ except OSError:
84
+ if blocking:
85
+ # A blocking acquire that errored (not a would-block) — fail
86
+ # open so batch work still makes progress.
87
+ acquired = True
88
+ else:
89
+ acquired = False
90
+ yield acquired
91
+ except OSError:
92
+ # Could not open the lock file at all — fail open.
93
+ yield True
94
+ return
95
+ finally:
96
+ if lock_fd is not None:
97
+ try:
98
+ if acquired:
99
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
100
+ except OSError:
101
+ pass
102
+ lock_fd.close()
@@ -0,0 +1,213 @@
1
+ """Durable per-session retain watermark (switchroom #3244).
2
+
3
+ Records, per ``(agent, session_id)``, the identity of the last transcript
4
+ entry that has been *confirmed* committed to the Hindsight bank. This is the
5
+ fact-on-disk that boot reconciliation (``reconcile_tail.py``) diffs against
6
+ the surviving ``.jsonl`` transcript to recover work an abrupt session death
7
+ (SIGKILL / OOM / watchdog) would otherwise silently drop.
8
+
9
+ Design (design-20260715.md §1.1):
10
+
11
+ * **Key = the message ``uuid`` of the last committed transcript entry.** Not
12
+ a turn ordinal (compaction re-indexes) and not a byte offset (shifts on
13
+ compaction); the ``uuid`` Claude Code stamps on every ``.jsonl`` entry is
14
+ stable across compaction and unambiguous. A ``byte_offset_hint`` is stored
15
+ as a fast-path only and never trusted alone.
16
+ * **Advance ONLY on confirmed persistence.** ``commit()`` is called only after
17
+ a retain POST the caller made with commit-before-ack semantics
18
+ (``async_processing=False``) returned ok. A watermark that is *behind*
19
+ reality is safe (the reconciler re-POSTs, which upserts idempotently on the
20
+ deterministic ``document_id``); a watermark *ahead* of reality would skip
21
+ real work, so we never write it speculatively.
22
+ * **Never moves backward.** ``commit()`` compares the incoming ``last_uuid``'s
23
+ position against the stored one *in the current transcript*; a stored uuid
24
+ that compaction removed is treated as stale (accept the incoming commit) —
25
+ a lost anchor can only cause a safe re-upsert, never a skip.
26
+ * **Atomic + flock.** ``write tmp -> fsync -> os.replace`` under an exclusive
27
+ flock (mirrors ``lib/state.py`` / ``lib/pending.py``) so a concurrent async
28
+ Stop hook and a SessionEnd force-retain writing the same session serialize
29
+ and never observe a torn file.
30
+
31
+ Location: ``$HOME/.hindsight/retained/<session_id>.json`` — same
32
+ container-lifetime persistence semantics as ``pending-retains/``. Losing it on
33
+ container recreate is safe: no watermark => the reconciler treats the bounded
34
+ transcript tail as the gap and re-upserts.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import os
41
+ import sys
42
+ import time
43
+ from typing import Optional
44
+
45
+ if sys.platform != "win32":
46
+ import fcntl
47
+ else: # pragma: no cover - switchroom agents are Linux only
48
+ fcntl = None
49
+
50
+ SCHEMA = 1
51
+
52
+
53
+ def watermark_dir() -> str:
54
+ """Return the retained-watermark directory.
55
+
56
+ Override with ``HINDSIGHT_RETAINED_DIR`` (tests). Default:
57
+ ``$HOME/.hindsight/retained/``.
58
+ """
59
+ override = os.environ.get("HINDSIGHT_RETAINED_DIR")
60
+ if override:
61
+ return override
62
+ return os.path.join(os.path.expanduser("~"), ".hindsight", "retained")
63
+
64
+
65
+ def _ensure_dir() -> str:
66
+ d = watermark_dir()
67
+ if not os.path.isdir(d):
68
+ os.makedirs(d, mode=0o700, exist_ok=True)
69
+ else:
70
+ try:
71
+ mode = os.stat(d).st_mode & 0o777
72
+ if mode != 0o700:
73
+ os.chmod(d, 0o700)
74
+ except OSError:
75
+ pass
76
+ return d
77
+
78
+
79
+ def _safe_session(session_id: str) -> str:
80
+ """Sanitise a session id for use as a filename (no path traversal)."""
81
+ keep = [c if (c.isalnum() or c in "-_.") else "_" for c in (session_id or "unknown")]
82
+ name = "".join(keep)[:200]
83
+ # Never allow a name that resolves to a traversal component.
84
+ return name.replace("..", "_") or "unknown"
85
+
86
+
87
+ def _path(session_id: str) -> str:
88
+ return os.path.join(watermark_dir(), f"{_safe_session(session_id)}.json")
89
+
90
+
91
+ def _lock_path(session_id: str) -> str:
92
+ return os.path.join(watermark_dir(), f"{_safe_session(session_id)}.lock")
93
+
94
+
95
+ def load(session_id: str) -> Optional[dict]:
96
+ """Return the stored watermark dict for ``session_id``, or ``None``."""
97
+ p = _path(session_id)
98
+ if not os.path.isfile(p):
99
+ return None
100
+ try:
101
+ with open(p, encoding="utf-8") as f:
102
+ return json.load(f)
103
+ except (OSError, json.JSONDecodeError):
104
+ return None
105
+
106
+
107
+ def _atomic_write(session_id: str, entry: dict) -> None:
108
+ d = _ensure_dir()
109
+ final = os.path.join(d, f"{_safe_session(session_id)}.json")
110
+ tmp = final + ".tmp"
111
+ with open(tmp, "w", encoding="utf-8") as f:
112
+ json.dump(entry, f, ensure_ascii=False)
113
+ f.flush()
114
+ os.fsync(f.fileno())
115
+ os.chmod(tmp, 0o600)
116
+ os.replace(tmp, final)
117
+
118
+
119
+ def _would_regress(stored: Optional[dict], last_uuid: str, ordered_uuids) -> bool:
120
+ """True if committing ``last_uuid`` would move the watermark BACKWARD.
121
+
122
+ Uses the position of each uuid *in the current transcript* (``ordered_uuids``,
123
+ transcript order). Explicit stale-anchor branch (design §1.1): if the stored
124
+ ``last_uuid`` is not present in the current transcript it was compacted away
125
+ and cannot be positionally compared — treat the stored watermark as stale and
126
+ accept the incoming commit (never a skip, at worst a safe re-upsert).
127
+ """
128
+ if not stored:
129
+ return False
130
+ stored_uuid = stored.get("last_uuid")
131
+ if not stored_uuid or stored_uuid == last_uuid:
132
+ # No stored anchor, or an idempotent re-commit of the same anchor —
133
+ # neither is a backward move.
134
+ return False
135
+ if not ordered_uuids:
136
+ # Without transcript ordering we cannot prove regression; accept
137
+ # (safety leans toward re-upsert, never skip).
138
+ return False
139
+ try:
140
+ stored_idx = ordered_uuids.index(stored_uuid)
141
+ except ValueError:
142
+ # Stored anchor compacted away -> stale -> accept incoming.
143
+ return False
144
+ try:
145
+ incoming_idx = ordered_uuids.index(last_uuid)
146
+ except ValueError:
147
+ # Incoming uuid not in transcript (shouldn't happen); accept.
148
+ return False
149
+ # Regress only if the stored anchor is strictly AHEAD of the incoming one.
150
+ return stored_idx > incoming_idx
151
+
152
+
153
+ def commit(
154
+ session_id: str,
155
+ last_uuid: str,
156
+ document_id: str,
157
+ transcript_path: str = "",
158
+ ordered_uuids=None,
159
+ byte_offset_hint: Optional[int] = None,
160
+ ) -> Optional[dict]:
161
+ """Advance the watermark for ``session_id`` to ``last_uuid``.
162
+
163
+ Refuses to move backward (see ``_would_regress``). Returns the persisted
164
+ watermark dict, or the existing one if the commit was a no-op (regression /
165
+ idempotent). Best-effort: a write failure returns ``None`` and never raises
166
+ — a successful retain must never be failed by a watermark write.
167
+
168
+ ``ordered_uuids`` is the list of transcript entry uuids in transcript order,
169
+ used for the monotonic comparison; the caller (retain / reconcile) has the
170
+ transcript in hand.
171
+ """
172
+ if not last_uuid:
173
+ # Nothing to anchor on (legacy/flat transcript with no uuids). We do
174
+ # not persist a uuid-less watermark: the reconciler falls back to
175
+ # treating the bounded tail as the gap, which upserts idempotently.
176
+ return None
177
+ lock_path = _lock_path(session_id)
178
+ entry = {
179
+ "schema": SCHEMA,
180
+ "session_id": session_id,
181
+ "last_uuid": last_uuid,
182
+ "last_document_id": document_id,
183
+ "committed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
184
+ "transcript_path": transcript_path,
185
+ }
186
+ if byte_offset_hint is not None:
187
+ entry["byte_offset_hint"] = int(byte_offset_hint)
188
+
189
+ def _do() -> Optional[dict]:
190
+ stored = load(session_id)
191
+ if _would_regress(stored, last_uuid, ordered_uuids):
192
+ return stored
193
+ _atomic_write(session_id, entry)
194
+ return entry
195
+
196
+ if fcntl is not None:
197
+ try:
198
+ _ensure_dir()
199
+ lock_fd = open(lock_path, "w")
200
+ try:
201
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
202
+ return _do()
203
+ finally:
204
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
205
+ lock_fd.close()
206
+ except OSError:
207
+ pass
208
+
209
+ # Fallback without lock (best-effort).
210
+ try:
211
+ return _do()
212
+ except OSError:
213
+ return None