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.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- 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
|
-
#
|
|
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(
|
|
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(
|
|
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
|
|