switchroom 0.19.18 → 0.19.19

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 (38) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +3 -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 +3392 -1569
  7. package/dist/host-control/main.js +12209 -11396
  8. package/dist/vault/approvals/kernel-server.js +60 -7
  9. package/dist/vault/broker/server.js +206 -76
  10. package/package.json +4 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/telegram-plugin/bridge/bridge.ts +14 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1644 -1044
  15. package/telegram-plugin/dist/server.js +13 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  17. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  18. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  19. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  20. package/telegram-plugin/gateway/store-file.ts +244 -0
  21. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  22. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  23. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  24. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  25. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  26. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  27. package/telegram-plugin/worker-activity-feed.ts +51 -1
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  29. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  30. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  31. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  32. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  37. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  38. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -5,12 +5,15 @@ Openclaw HindsightClient (client.js), adapted for Python stdlib.
5
5
  """
6
6
 
7
7
  import json
8
+ import time
8
9
  import urllib.error
9
10
  import urllib.parse
10
11
  import urllib.request
11
12
  from pathlib import Path
12
13
  from typing import Optional
13
14
 
15
+ from .retain_split import part_document_id, part_metadata, split_retain_content
16
+
14
17
  DEFAULT_TIMEOUT = 15 # seconds
15
18
  HEALTH_CHECK_RETRIES = 3
16
19
  HEALTH_CHECK_DELAY = 2 # seconds
@@ -189,7 +192,85 @@ class HindsightClient:
189
192
  reconciliation) MUST use this so a bare async 200 can never falsely mark
190
193
  unpersisted work as committed. (Merge precondition: the daemon honours
191
194
  ``async=false`` as commit-before-ack — verified by the §1.1 probe.)
195
+
196
+ **Oversized content is split before it is posted** (``lib/retain_split``).
197
+ The daemon runs one sequential extraction LLM call per
198
+ ``retain_chunk_size`` (3000) chars, so server wall time is linear in
199
+ ``len(content)`` while the client has a single deadline for the whole
200
+ POST — past ~45,000 chars a retain cannot complete at ANY client
201
+ timeout and the memory is permanently unsaveable (measured: 154 of the
202
+ 629 entries in the 2026-07-25 fleet backlog). This is the enforcement
203
+ point precisely because every retain POST in the plugin goes through
204
+ it, so the bound is code-enforced rather than left to each producer.
205
+
206
+ Parts are posted SEQUENTIALLY, each with the caller's ``timeout`` (the
207
+ timeout is a per-HTTP-request read deadline, and each part is its own
208
+ request) and each with the caller's ``async_processing`` — so a
209
+ durability caller still gets commit-before-ack per part. Any part
210
+ failing raises, exactly as an unsplit failure does, so the caller's
211
+ existing enqueue/retry handling is unchanged; already-committed parts
212
+ carry deterministic ids and are upserted, not duplicated, on retry.
213
+
214
+ ``timeout`` bounds the CALLER'S TOTAL WALL TIME, not just each HTTP
215
+ request. Splitting must never turn one bounded POST into N of them:
216
+ the Stop hook passes ``timeout=15`` because it has a hook budget to
217
+ respect, and ``15 × N`` would stall the session. Once the budget is
218
+ spent no further part is started and the call raises, exactly as an
219
+ unsplit failure does, so each caller's EXISTING failure path handles
220
+ the remainder: the hook paths (``retain.py``, ``subagent_retain.py``,
221
+ ``reconcile_tail.py``) enqueue, and ``pending.enqueue`` queues the
222
+ remainder as bounded per-part entries the drainer can finish; the
223
+ drain paths count an attempt and keep the entry;
224
+ ``backfill_transcripts.py`` logs and backs off. The parts already
225
+ committed are upserted on the next attempt, not duplicated.
192
226
  """
227
+ parts = split_retain_content(content)
228
+ total = len(parts)
229
+ response = None
230
+ deadline = time.monotonic() + timeout
231
+ part_timeout = timeout
232
+ for index, part in enumerate(parts):
233
+ if index > 0:
234
+ remaining = deadline - time.monotonic()
235
+ if remaining <= 0:
236
+ raise TimeoutError(
237
+ f"retain wall budget of {timeout}s exhausted after "
238
+ f"{index}/{total} parts; no further part was started. "
239
+ f"The caller's own failure path decides the remainder: "
240
+ f"the hook paths enqueue it (as bounded per-part "
241
+ f"entries), the drain paths count an attempt and keep "
242
+ f"the entry queued."
243
+ )
244
+ # Clamp the request deadline to what is left of the budget so
245
+ # the final part cannot overrun it either.
246
+ part_timeout = max(1, int(remaining))
247
+ response = self._retain_one(
248
+ bank_id=bank_id,
249
+ content=part,
250
+ document_id=part_document_id(document_id, index, total),
251
+ context=context,
252
+ metadata=part_metadata(metadata, index, total),
253
+ tags=tags,
254
+ timeout=part_timeout,
255
+ async_processing=async_processing,
256
+ )
257
+ if total > 1 and isinstance(response, dict):
258
+ response = dict(response)
259
+ response["split_parts"] = total
260
+ return response
261
+
262
+ def _retain_one(
263
+ self,
264
+ bank_id: str,
265
+ content: str,
266
+ document_id: str,
267
+ context: Optional[str],
268
+ metadata: Optional[dict],
269
+ tags: Optional[list],
270
+ timeout: int,
271
+ async_processing: bool,
272
+ ) -> dict:
273
+ """POST exactly one retain item. Raises on any HTTP/transport error."""
193
274
  path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
194
275
  item = {
195
276
  "content": content,
@@ -206,6 +287,49 @@ class HindsightClient:
206
287
  }
207
288
  return self._request("POST", path, body, timeout=timeout)
208
289
 
290
+ def document_exists(
291
+ self,
292
+ bank_id: str,
293
+ document_id: str,
294
+ timeout: int = 30,
295
+ ) -> Optional[bool]:
296
+ """TRI-STATE presence check for one document (switchroom #3596).
297
+
298
+ ``True`` — the document is there. ``False`` — the server said 404.
299
+ ``None`` — **unknown** (transport error, 5xx, timeout).
300
+
301
+ The tri-state is load-bearing and must not be collapsed to a bool.
302
+ Two callers depend on it:
303
+
304
+ * the backlog drain's reconcile phase, which SKIPS a retain when the
305
+ memory already exists. Treating "unknown" as ``False`` there would
306
+ re-POST a document that is already durable — the duplicated-LLM-cost
307
+ bug this check exists to avoid (70.4% of one measured 5,751-entry
308
+ fleet backlog already existed as documents).
309
+ * commit-before-delete, which only deletes a queue entry once presence
310
+ is CONFIRMED. Treating "unknown" as ``True`` there would delete the
311
+ last on-disk copy of a turn on a flaky GET — the #3244 silent-loss
312
+ shape, reintroduced from the other direction.
313
+
314
+ Deliberately does NOT reuse ``_request()``: that wraps every
315
+ ``HTTPError`` into a ``RuntimeError``, which would make a 404
316
+ indistinguishable from a 503 without string-matching the message.
317
+ """
318
+ bank = urllib.parse.quote(bank_id, safe="")
319
+ did = urllib.parse.quote(document_id, safe="")
320
+ url = f"{self.api_url}/v1/default/banks/{bank}/documents/{did}"
321
+ req = urllib.request.Request(url, headers=self._headers(), method="GET")
322
+ try:
323
+ with urllib.request.urlopen(
324
+ req, timeout=self._resolve_timeout(timeout)
325
+ ) as resp:
326
+ resp.read()
327
+ return True
328
+ except urllib.error.HTTPError as e:
329
+ return False if e.code == 404 else None
330
+ except Exception:
331
+ return None
332
+
209
333
  def list_session_document_ids(
210
334
  self,
211
335
  bank_id: str,