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.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +3 -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 +3392 -1569
- package/dist/host-control/main.js +12209 -11396
- package/dist/vault/approvals/kernel-server.js +60 -7
- package/dist/vault/broker/server.js +206 -76
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +1644 -1044
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/worker-activity-feed.ts +51 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
- 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 +2121 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
- 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,
|