switchroom 0.19.18 → 0.19.22

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -0,0 +1,460 @@
1
+ """Deterministic size bound for retain content, with structure-preserving split.
2
+
3
+ Why this exists
4
+ ---------------
5
+ A retain POST is *not* one model call. The Hindsight daemon chunks the
6
+ submitted content at ``retain_chunk_size`` chars and runs fact extraction as
7
+ one **sequential** LLM call per chunk
8
+ (``hindsight_api/config.py`` ``DEFAULT_RETAIN_CHUNK_SIZE = 3000``). So the
9
+ server-side wall time of a retain is linear in ``len(content)``, while the
10
+ client only ever gets ONE deadline for the whole POST. Past some content
11
+ length, no client timeout can cover the work and the retain can never succeed
12
+ — not on the live Stop hook, not on the SessionStart drain, not ever. Those
13
+ memories are permanently unsaveable.
14
+
15
+ Measured on the 2026-07-25 fleet backlog (629 queued entries): median content
16
+ 26,112 chars, p90 150,224, max 744,546. 154 entries exceeded 60,000 chars and
17
+ burned the full client deadline on every attempt. A 744,546-char entry is
18
+ ~249 sequential extraction calls.
19
+
20
+ Why the bound is enforced here and not server-side
21
+ --------------------------------------------------
22
+ The daemon *does* have an auto-splitter — ``retain_batch_tokens``
23
+ (``DEFAULT_RETAIN_BATCH_TOKENS = 10_000``, "Max chars per sub-batch for async
24
+ retain auto-splitting") — but it only applies on the ``async=True`` path.
25
+ Every durability retain in this plugin posts ``async=False`` on purpose
26
+ (commit-before-ack, switchroom #3244 §1.1): the 200 must prove durable
27
+ persistence because a 200 is what lets the caller delete the queue entry and
28
+ advance the watermark. So the one server-side mechanism that would help is
29
+ structurally unavailable to us. It is also the wrong layer: the server
30
+ receives an opaque string and could only cut it at a byte offset, whereas the
31
+ plugin still knows the transcript's message structure and can cut on a
32
+ message boundary.
33
+
34
+ The bound is applied at ``HindsightClient.retain()`` — the single function
35
+ every retain POST in this plugin goes through (Stop hook, SessionEnd,
36
+ drain_pending, reconcile_tail, backfill_transcripts, subagent_retain) — so it
37
+ is code-enforced for every present and future caller rather than something a
38
+ producer has to remember.
39
+
40
+ Splitting rules
41
+ ---------------
42
+ Splits are taken on transcript structure, never on a raw byte offset, so a
43
+ part is always a syntactically complete transcript with role attribution
44
+ intact:
45
+
46
+ * JSON transcript (``retainToolCalls`` default, a JSON array of
47
+ ``{role, content:[blocks]}``): split the array into groups; each part
48
+ re-serializes as a valid JSON array.
49
+ * Text transcript (``[role: x]\\n…\\n[x:end]`` blocks joined by a blank
50
+ line): split on block boundaries; each part is a whole number of blocks.
51
+ * An atom (one message / one block) larger than the bound is split at line
52
+ boundaries and each fragment is re-wrapped in the SAME role envelope, so
53
+ context is still attributed rather than stranded.
54
+ * Last resort, only if the structural passes cannot get a part under the
55
+ bound (e.g. one unbroken 100k-char line): a hard character slice. The size
56
+ invariant wins over structure at that extreme, because a part over the
57
+ bound is a part that never persists at all.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import json
63
+ import os
64
+ from typing import Optional
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # The bound — derived, not chosen
69
+ # ---------------------------------------------------------------------------
70
+ #
71
+ # max_content_chars = retain_chunk_size × floor(client_deadline / chunk_latency)
72
+ #
73
+ # Each input is a measured or read property of the deployment, not a taste
74
+ # call, and each is env-overridable so the bound tracks the deployment:
75
+ #
76
+ # retain_chunk_size 3000 s — server-side chars per extraction chunk.
77
+ # `hindsight_api/config.py`
78
+ # DEFAULT_RETAIN_CHUNK_SIZE = 3000. One chunk =
79
+ # one sequential LLM call.
80
+ # chunk_latency 18.4 s — measured mean wall time of a healthy retain
81
+ # extraction call, n=752, LiteLLM SpendLogs
82
+ # 2026-07-25 07:00–08:05 UTC (the 0–7,941
83
+ # completion-token bucket; the runaway bucket is
84
+ # a separate defect, fixed by capping
85
+ # HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS).
86
+ # client_deadline 310 s — the deadline of the DURABILITY path (the
87
+ # out-of-hook backlog drain, #3599), deliberately
88
+ # not the live Stop hook's 15s. The live path is
89
+ # allowed to miss its deadline: it enqueues to
90
+ # pending-retains and the drain retries. Sizing
91
+ # to 15s would cut content to a single chunk and
92
+ # shred every transcript for no gain.
93
+ # This is the ONE definition of that deadline:
94
+ # `drain_pending._backlog_timeout()` defaults to
95
+ # `retain_client_deadline()` rather than a second
96
+ # literal, and `src/setup/hindsight.ts`
97
+ # (`HINDSIGHT_RETAIN_CLIENT_DEADLINE_S`, #3611)
98
+ # mirrors it as the client half of that PR's
99
+ # `hindsight per-call timeout < client deadline`
100
+ # assertion. A test in `tests/setup/hindsight.test.ts`
101
+ # enforces that the two stay equal, so the mirror
102
+ # is checked, not merely asserted in prose.
103
+ # WAS 280.0. Raised to 310 when the retain
104
+ # per-call timeout became derived from the litellm
105
+ # routing chain (`local 200 + fallback 90 +
106
+ # margin 10 = 300`) instead of from the token
107
+ # budget alone: #3611's 204s could not cover that
108
+ # chain, so the retain OpenRouter fallback hop had
109
+ # 4s of headroom and could never complete. 310 is
110
+ # that 300 plus the same one margin, so the plugin
111
+ # always outlives hindsight by construction. It is
112
+ # NOT a hand-set number on either side — change
113
+ # `src/litellm/timeout-budget.ts` and both move.
114
+ #
115
+ # floor(310 / 18.4) = 16 chunks → 16 × 3000 = 48,000 chars
116
+ #
117
+ # Sanity check against the same backlog: every entry at or below 60,000 chars
118
+ # drained successfully inside the (then 280s) deadline (observed per-entry
119
+ # times 0.3s–153.4s at concurrency 3), so 48,000 still sits inside
120
+ # demonstrated-good territory with margin for a slower model or a busier box.
121
+ DEFAULT_RETAIN_CHUNK_SIZE = 3000
122
+ DEFAULT_RETAIN_CHUNK_LATENCY_S = 18.4
123
+ DEFAULT_RETAIN_CLIENT_DEADLINE_S = 310.0
124
+
125
+ # Absolute floor: one chunk. A bound below one chunk would split every
126
+ # transcript into extraction-sized confetti and is never the right answer.
127
+ MIN_RETAIN_CONTENT_CHARS = DEFAULT_RETAIN_CHUNK_SIZE
128
+
129
+
130
+ def _env_number(name: str, default: float) -> float:
131
+ raw = os.environ.get(name)
132
+ if not raw:
133
+ return default
134
+ try:
135
+ value = float(raw)
136
+ except (TypeError, ValueError):
137
+ return default
138
+ return value if value > 0 else default
139
+
140
+
141
+ def retain_client_deadline() -> float:
142
+ """Seconds a DURABILITY caller waits for one retain POST.
143
+
144
+ The single definition of that deadline. ``retain_content_limit()`` sizes
145
+ content so a whole POST fits inside it, and
146
+ ``drain_pending._backlog_timeout()`` takes it as its default so the drainer
147
+ actually waits that long — a shorter drain deadline would abandon a
148
+ correctly-sized part mid-extraction and rebuild the very re-post loop #3599
149
+ fixed, one size class up.
150
+ """
151
+ return _env_number("HINDSIGHT_RETAIN_CLIENT_DEADLINE_S", DEFAULT_RETAIN_CLIENT_DEADLINE_S)
152
+
153
+
154
+ def retain_content_limit() -> int:
155
+ """Max chars of retain content that can complete inside the client deadline.
156
+
157
+ Recomputed per call (cheap) so a test or an operator can move an input via
158
+ the environment without reimporting the module.
159
+ """
160
+ override = os.environ.get("HINDSIGHT_RETAIN_MAX_CONTENT_CHARS")
161
+ if override:
162
+ try:
163
+ forced = int(override)
164
+ if forced > 0:
165
+ return max(MIN_RETAIN_CONTENT_CHARS, forced)
166
+ except (TypeError, ValueError):
167
+ pass
168
+
169
+ chunk_size = int(_env_number("HINDSIGHT_RETAIN_CHUNK_SIZE", DEFAULT_RETAIN_CHUNK_SIZE))
170
+ latency = _env_number("HINDSIGHT_RETAIN_CHUNK_LATENCY_S", DEFAULT_RETAIN_CHUNK_LATENCY_S)
171
+ deadline = retain_client_deadline()
172
+
173
+ chunks = int(deadline // latency)
174
+ if chunks < 1:
175
+ chunks = 1
176
+ return max(MIN_RETAIN_CONTENT_CHARS, chunk_size * chunks)
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Part identity
181
+ # ---------------------------------------------------------------------------
182
+
183
+ def part_document_id(document_id: str, index: int, total: int) -> str:
184
+ """Document id for part ``index`` (0-based) of ``total``.
185
+
186
+ ``total <= 1`` returns the id unchanged, so an unsplit retain keeps EXACTLY
187
+ the id it has today and its upsert convergence (retain.py
188
+ ``slice_document_id``) is untouched.
189
+
190
+ For a split, ``{base}-p{i}of{n}``: deterministic (same content + same bound
191
+ ⇒ same parts ⇒ same ids, so a retry upserts rather than duplicates), unique
192
+ per part (parts must never overwrite each other — that would be silent
193
+ loss), and prefix-preserving, so the ``{session_id}`` prefix probe in
194
+ ``client.list_session_document_ids`` still finds them.
195
+ """
196
+ if total <= 1:
197
+ return document_id
198
+ return f"{document_id}-p{index + 1}of{total}"
199
+
200
+
201
+ def part_metadata(metadata: Optional[dict], index: int, total: int) -> dict:
202
+ """Metadata for part ``index`` of ``total``, carrying provenance.
203
+
204
+ Unsplit retains are returned untouched (a copy). A split stamps the part
205
+ position, so any one part says where it sits in the logical retain and the
206
+ base document id is recoverable from the part id's ``-p{i}of{n}`` suffix.
207
+ Values are strings to match the existing metadata convention
208
+ (``retain.py`` writes ``message_count`` as a string).
209
+ """
210
+ base = dict(metadata or {})
211
+ if total <= 1:
212
+ return base
213
+ base["retain_part_index"] = str(index + 1)
214
+ base["retain_part_count"] = str(total)
215
+ return base
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Splitting
220
+ # ---------------------------------------------------------------------------
221
+
222
+ _TEXT_BLOCK_SEP = "\n\n"
223
+
224
+
225
+ def split_retain_content(content: str, max_chars: Optional[int] = None) -> list:
226
+ """Split ``content`` into parts each at most ``max_chars`` long.
227
+
228
+ Returns ``[content]`` unchanged when it already fits — the overwhelmingly
229
+ common case, and the one where behaviour must not change at all.
230
+
231
+ Guarantees, all asserted by the test suite:
232
+ * every part is non-empty and ``<= max_chars``
233
+ * concatenating the parts loses no *message*; only the pathological
234
+ single-unbreakable-line case cuts inside text
235
+ * the split is a pure function of (content, max_chars) — no clock, no
236
+ randomness — so retries converge on identical parts and identical ids
237
+ """
238
+ limit = max_chars if (max_chars and max_chars > 0) else retain_content_limit()
239
+ # Non-string / empty content is passed straight through: the retain path's
240
+ # existing behaviour for it is not this module's business to change.
241
+ if not isinstance(content, str) or len(content) <= limit:
242
+ return [content]
243
+
244
+ parts = _split_json(content, limit)
245
+ if parts is None:
246
+ parts = _split_text(content, limit)
247
+
248
+ # Normalisation: structure-preserving passes are best-effort; the size
249
+ # bound is not. Anything still over the limit gets hard-sliced, because a
250
+ # part over the limit is a part that never persists.
251
+ normalised: list = []
252
+ for part in parts:
253
+ if len(part) <= limit:
254
+ if part:
255
+ normalised.append(part)
256
+ else:
257
+ normalised.extend(_hard_split(part, limit))
258
+ return normalised or [content[:limit]]
259
+
260
+
261
+ def _hard_split(text: str, limit: int) -> list:
262
+ """Last-resort fixed-width slice. Always satisfies the bound."""
263
+ return [text[i : i + limit] for i in range(0, len(text), limit)] or [""]
264
+
265
+
266
+ def _json_dump(value) -> str:
267
+ # Must match _prepare_json_transcript in lib/content.py exactly, or a part
268
+ # would measure differently here than it serialises there.
269
+ return json.dumps(value, indent=None, ensure_ascii=False)
270
+
271
+
272
+ def _split_json(content: str, limit: int) -> Optional[list]:
273
+ """Split a JSON-array transcript on message boundaries.
274
+
275
+ Returns ``None`` (not a partial result) when ``content`` is not the JSON
276
+ transcript shape, so the caller falls through to the text splitter.
277
+ """
278
+ stripped = content.lstrip()
279
+ if not stripped.startswith("["):
280
+ return None
281
+ try:
282
+ messages = json.loads(content)
283
+ except (ValueError, TypeError):
284
+ return None
285
+ if not isinstance(messages, list) or not messages:
286
+ return None
287
+
288
+ # Expand any single message that cannot fit on its own, so the grouping
289
+ # pass below only ever sees messages that individually fit.
290
+ expanded: list = []
291
+ for message in messages:
292
+ serialised = _json_dump([message])
293
+ if len(serialised) <= limit:
294
+ expanded.append(message)
295
+ else:
296
+ expanded.extend(_split_json_message(message, limit))
297
+
298
+ parts: list = []
299
+ group: list = []
300
+ for message in expanded:
301
+ candidate = group + [message]
302
+ if group and len(_json_dump(candidate)) > limit:
303
+ parts.append(_json_dump(group))
304
+ group = [message]
305
+ else:
306
+ group = candidate
307
+ if group:
308
+ parts.append(_json_dump(group))
309
+ return parts
310
+
311
+
312
+ def _split_json_message(message, limit: int) -> list:
313
+ """Split ONE oversized JSON message into several same-role messages.
314
+
315
+ Preserves the role envelope on every fragment: the model still sees who
316
+ said what, which is the context that a naive byte cut strands.
317
+ """
318
+ if not isinstance(message, dict):
319
+ return [message]
320
+ role = message.get("role", "unknown")
321
+ blocks = message.get("content")
322
+ if not isinstance(blocks, list) or not blocks:
323
+ return [message]
324
+
325
+ # Envelope cost of a one-message array with no blocks — the budget that
326
+ # blocks have to fit inside.
327
+ envelope = len(_json_dump([{"role": role, "content": []}]))
328
+ budget = limit - envelope
329
+ if budget <= 0:
330
+ return [message]
331
+
332
+ # Expand oversized individual blocks first (a single giant tool_result).
333
+ atoms: list = []
334
+ for block in blocks:
335
+ if len(_json_dump(block)) <= budget:
336
+ atoms.append(block)
337
+ else:
338
+ atoms.extend(_split_json_block(block, budget))
339
+
340
+ out: list = []
341
+ group: list = []
342
+ for atom in atoms:
343
+ candidate = group + [atom]
344
+ if group and len(_json_dump(candidate)) > budget:
345
+ out.append({"role": role, "content": group})
346
+ group = [atom]
347
+ else:
348
+ group = candidate
349
+ if group:
350
+ out.append({"role": role, "content": group})
351
+ return out or [message]
352
+
353
+
354
+ def _split_json_block(block, budget: int) -> list:
355
+ """Split one oversized content block by slicing its longest text field."""
356
+ if not isinstance(block, dict):
357
+ return [block]
358
+ field = None
359
+ for candidate in ("text", "content", "input", "output"):
360
+ if isinstance(block.get(candidate), str) and block[candidate]:
361
+ field = candidate
362
+ break
363
+ if field is None:
364
+ return [block]
365
+
366
+ # Room the text has once the rest of the block is serialised.
367
+ skeleton = dict(block)
368
+ skeleton[field] = ""
369
+ room = budget - len(_json_dump(skeleton))
370
+ if room <= 0:
371
+ return [block]
372
+
373
+ out = []
374
+ for fragment in _split_text_by_lines(block[field], room):
375
+ clone = dict(block)
376
+ clone[field] = fragment
377
+ out.append(clone)
378
+ return out or [block]
379
+
380
+
381
+ def _split_text(content: str, limit: int) -> list:
382
+ """Split a ``[role: x]…[x:end]`` transcript on block boundaries."""
383
+ blocks = content.split(_TEXT_BLOCK_SEP)
384
+
385
+ expanded: list = []
386
+ for block in blocks:
387
+ if len(block) <= limit:
388
+ expanded.append(block)
389
+ else:
390
+ expanded.extend(_split_text_block(block, limit))
391
+
392
+ parts: list = []
393
+ group: list = []
394
+ group_len = 0
395
+ sep_len = len(_TEXT_BLOCK_SEP)
396
+ for block in expanded:
397
+ added = len(block) + (sep_len if group else 0)
398
+ if group and group_len + added > limit:
399
+ parts.append(_TEXT_BLOCK_SEP.join(group))
400
+ group, group_len = [block], len(block)
401
+ else:
402
+ group.append(block)
403
+ group_len += added
404
+ if group:
405
+ parts.append(_TEXT_BLOCK_SEP.join(group))
406
+ return parts
407
+
408
+
409
+ def _split_text_block(block: str, limit: int) -> list:
410
+ """Split one oversized ``[role: x]…[x:end]`` block, re-wrapping each part.
411
+
412
+ Falls back to a plain line split when the block is not in the marker form
413
+ (e.g. a legacy flat transcript), which the caller's normalisation pass
414
+ then hard-slices if any fragment is still over the bound.
415
+ """
416
+ lines = block.split("\n")
417
+ if len(lines) < 3 or not lines[0].startswith("[role: ") or not lines[-1].endswith(":end]"):
418
+ return _split_text_by_lines(block, limit)
419
+
420
+ header, footer = lines[0], lines[-1]
421
+ body = "\n".join(lines[1:-1])
422
+ # +2 for the two newlines that rejoin header/body/footer.
423
+ room = limit - len(header) - len(footer) - 2
424
+ if room <= 0:
425
+ return _split_text_by_lines(block, limit)
426
+ return [f"{header}\n{fragment}\n{footer}" for fragment in _split_text_by_lines(body, room)]
427
+
428
+
429
+ def _split_text_by_lines(text: str, limit: int) -> list:
430
+ """Group whole lines into fragments of at most ``limit`` chars.
431
+
432
+ A single line longer than ``limit`` is hard-sliced — the only place this
433
+ module cuts inside a line, and unavoidable: an unbroken 100k-char line has
434
+ no structural boundary to cut on.
435
+ """
436
+ if limit <= 0:
437
+ return [text]
438
+ if len(text) <= limit:
439
+ return [text]
440
+
441
+ out: list = []
442
+ buf: list = []
443
+ buf_len = 0
444
+ for line in text.split("\n"):
445
+ if len(line) > limit:
446
+ if buf:
447
+ out.append("\n".join(buf))
448
+ buf, buf_len = [], 0
449
+ out.extend(_hard_split(line, limit))
450
+ continue
451
+ added = len(line) + (1 if buf else 0)
452
+ if buf and buf_len + added > limit:
453
+ out.append("\n".join(buf))
454
+ buf, buf_len = [line], len(line)
455
+ else:
456
+ buf.append(line)
457
+ buf_len += added
458
+ if buf:
459
+ out.append("\n".join(buf))
460
+ return out or [text]
@@ -1296,6 +1296,56 @@ def _combine_context(base, nudge) -> str:
1296
1296
  return "\n\n".join(parts)
1297
1297
 
1298
1298
 
1299
+ def degraded_recall_notice(bank_id, bank_timings) -> str:
1300
+ """Switchroom #3619 — return the degraded-recall disclosure for this turn,
1301
+ or "" when the agent's own bank answered.
1302
+
1303
+ Until now a recall whose own bank timed out was indistinguishable, from the
1304
+ agent's side, from a bank that genuinely held nothing relevant: both
1305
+ produced an empty block and silence. That ambiguity is what let a measured
1306
+ ~90% own-bank timeout rate run for weeks unnoticed while every agent's
1307
+ CLAUDE.md asserted recall "auto-fires on every inbound message" — the agent
1308
+ had no way to know it was answering from an empty context, so it never said
1309
+ so and the operator never saw it.
1310
+
1311
+ Only the agent's OWN bank warrants the notice: additional banks (a shared
1312
+ profile bank, say) are supplementary, and a side-bank timeout does not mean
1313
+ the agent lost its own memory. Matching is by `bank_id`, never by position
1314
+ in `bank_timings` — the fan-out order is not stable.
1315
+
1316
+ Kept to a single short line on purpose: this fires on an already-degraded
1317
+ turn, and a verbose block would spend the very budget the degradation is
1318
+ starving. The caller must keep it OUT of the cached context (see
1319
+ `_combine_context`) — it is per-turn state and would otherwise replay on a
1320
+ later healthy cache hit.
1321
+ """
1322
+ if not bank_id or not bank_timings:
1323
+ return ""
1324
+ own = next(
1325
+ (
1326
+ bt
1327
+ for bt in bank_timings
1328
+ if isinstance(bt, dict) and bt.get("bank_id") == bank_id
1329
+ ),
1330
+ None,
1331
+ )
1332
+ if not own:
1333
+ return ""
1334
+ if own.get("timed_out"):
1335
+ reason = "timed out"
1336
+ elif own.get("errored"):
1337
+ reason = "was unreachable"
1338
+ else:
1339
+ return ""
1340
+ return (
1341
+ f"[Hindsight] Memory recall was DEGRADED this turn: your own bank "
1342
+ f"('{bank_id}') {reason}, so the memories below (if any) are "
1343
+ f"incomplete and may be missing entirely. Treat an absence of "
1344
+ f"relevant memory as UNKNOWN, not as 'nothing was remembered' — "
1345
+ f"say so rather than asserting there is no prior context."
1346
+ )
1347
+
1348
+
1299
1349
  def main():
1300
1350
  config = load_config()
1301
1351
 
@@ -2129,13 +2179,22 @@ def main():
2129
2179
  "transcript_fallback_truncated": transcript_fallback_telemetry["truncated"],
2130
2180
  })
2131
2181
 
2132
- # If neither block has content, there's nothing to inject — exit
2182
+ # Switchroom #3619 DEGRADED-RECALL DISCLOSURE. See
2183
+ # `degraded_recall_notice` for why this exists and why only the agent's
2184
+ # OWN bank counts.
2185
+ degraded_block = degraded_recall_notice(bank_id, bank_timings)
2186
+
2187
+ # If no block has content, there's nothing to inject — exit
2133
2188
  # silently to avoid emitting an empty hookSpecificOutput. #2848: unless
2134
2189
  # the directive-capture nudge fired, in which case emit the nudge alone
2135
2190
  # (a correction with no memories/directives still needs the reminder).
2191
+ # #3619: a degraded own-bank read is likewise worth emitting alone — that
2192
+ # is precisely the turn on which the agent must not assume it remembers.
2136
2193
  if not directives_block and not memories_block and not transcript_fallback_block:
2137
- if nudge_block:
2138
- _emit_cached_context(nudge_block)
2194
+ if degraded_block or nudge_block:
2195
+ _emit_cached_context(
2196
+ "\n\n".join([b for b in (degraded_block, nudge_block) if b])
2197
+ )
2139
2198
  return
2140
2199
 
2141
2200
  # Compose final context. Directives block goes ABOVE memories so the
@@ -2143,6 +2202,12 @@ def main():
2143
2202
  # transcript fallback (#3369) goes LAST — it is the lowest-confidence
2144
2203
  # signal (raw transcript, not synthesized fact) and only present when
2145
2204
  # memories_block is empty by construction.
2205
+ #
2206
+ # #3619's degraded notice is deliberately NOT part of context_message: like
2207
+ # the #2848 nudge it is per-turn state, and this string is what gets cached
2208
+ # and written to LAST_RECALL_STATE. Caching it would replay "recall was
2209
+ # DEGRADED" on later healthy cache hits; it is prepended at emit time
2210
+ # instead, so a cache hit re-derives the turn's real condition.
2146
2211
  parts = []
2147
2212
  if directives_block:
2148
2213
  parts.append(directives_block)
@@ -2177,11 +2242,15 @@ def main():
2177
2242
 
2178
2243
  # Output JSON for Claude Code hook system. #2848: append the
2179
2244
  # directive-capture nudge (if it fired) at emit time — it's kept out of
2180
- # the cached / last-recall context above so it can't go stale.
2245
+ # the cached / last-recall context above so it can't go stale. #3619: the
2246
+ # degraded-recall notice is prepended for the same reason, and goes FIRST
2247
+ # because it changes how everything after it should be read.
2181
2248
  output = {
2182
2249
  "hookSpecificOutput": {
2183
2250
  "hookEventName": "UserPromptSubmit",
2184
- "additionalContext": _combine_context(context_message, nudge_block),
2251
+ "additionalContext": _combine_context(
2252
+ _combine_context(degraded_block, context_message), nudge_block
2253
+ ),
2185
2254
  }
2186
2255
  }
2187
2256
  json.dump(output, sys.stdout)
@@ -20,6 +20,48 @@ from lib.config import debug_log, load_config
20
20
  from lib.daemon import get_api_url, prestart_daemon_background
21
21
 
22
22
 
23
+ #: Marker every version-skew warning carries, so log scrapers and tests
24
+ #: match one stable string rather than the prose around it.
25
+ SKEW_MARKER = "PARTIAL PLUGIN ROLLOUT"
26
+
27
+
28
+ def _warn_version_skew(module: str, err: BaseException, config: dict, cost: str) -> None:
29
+ """Report an import-level version skew LOUDLY, on stderr (#3599 R4-Lc).
30
+
31
+ The plugin tree deploys as loose files under ``scripts/``, and this repo
32
+ removes symbols across versions — ``pending.delete_entry``, whose last
33
+ importer was ``drain_pending``, was removed in #3599 itself. A PARTIAL
34
+ rollout (new ``lib/pending.py`` beside an old ``drain_pending.py``)
35
+ therefore raises ``ImportError`` at the hook's import line.
36
+
37
+ Before this guard that landed in a bare ``except Exception`` and went to
38
+ ``debug_log``, which is off by default: the durability machinery would
39
+ stop and nothing would say so. A backlog then grows until ``switchroom
40
+ doctor`` notices it — and doctor names the QUEUE, not the cause. This
41
+ turns a silent deploy fault into an every-boot stderr line naming the
42
+ module, the missing symbol and the fix.
43
+
44
+ Deliberately not keyed to ``delete_entry`` or to a version number: any
45
+ import-level skew in this tree has the same cause and the same fix, so
46
+ the check generalises to removals that have not happened yet. A version
47
+ stamp would need bumping to stay true; this cannot go stale.
48
+
49
+ Never raises and never re-raises — a skew must not take SessionStart
50
+ down on top of everything else.
51
+ """
52
+ print(
53
+ f"[Hindsight] SessionStart: cannot import {module} ({err}). "
54
+ f"{SKEW_MARKER} — the files under the plugin's scripts/ are from "
55
+ f"different versions. {cost} until this is fixed. Re-deploy the "
56
+ f"plugin tree WHOLE (all of scripts/, never individual files).",
57
+ file=sys.stderr,
58
+ )
59
+ try:
60
+ debug_log(config, f"{module} import failed (version skew): {err}")
61
+ except Exception:
62
+ pass
63
+
64
+
23
65
  def main():
24
66
  config = load_config()
25
67
 
@@ -83,6 +125,8 @@ def main():
83
125
  from drain_pending import drain as drain_pending_retains
84
126
 
85
127
  drain_pending_retains(config)
128
+ except ImportError as e:
129
+ _warn_version_skew("drain_pending", e, config, "Queued retains will NOT be replayed")
86
130
  except Exception as e:
87
131
  # Never let the drain break session start. Issue sink picks
88
132
  # this up via run-hook.sh — see exit-code path in __main__.
@@ -99,6 +143,10 @@ def main():
99
143
  from reconcile_tail import reconcile as reconcile_tail
100
144
 
101
145
  reconcile_tail(config, hook_input=hook_input)
146
+ except ImportError as e:
147
+ _warn_version_skew(
148
+ "reconcile_tail", e, config, "Un-committed turns will NOT be recovered"
149
+ )
102
150
  except Exception as e:
103
151
  debug_log(config, f"reconcile_tail unexpected error (ignored): {e}")
104
152