switchroom 0.19.27 → 0.19.29

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 (44) hide show
  1. package/dist/agent-scheduler/index.js +16 -2
  2. package/dist/auth-broker/index.js +141 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +18 -2
  5. package/dist/cli/switchroom.js +864 -43
  6. package/dist/host-control/main.js +144 -9
  7. package/dist/vault/approvals/kernel-server.js +146 -9
  8. package/dist/vault/broker/server.js +146 -9
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +79 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +585 -50
  13. package/telegram-plugin/dist/server.js +1 -0
  14. package/telegram-plugin/edit-flood-fuse.ts +230 -27
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  16. package/telegram-plugin/gateway/gateway.ts +9 -2
  17. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  18. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  19. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  20. package/telegram-plugin/operator-events.ts +38 -0
  21. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
  22. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  23. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  24. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  25. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  26. package/vendor/hindsight-memory/CHANGELOG.md +50 -0
  27. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +1 -0
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +437 -11
  29. package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
  30. package/vendor/hindsight-memory/scripts/lib/config.py +91 -0
  31. package/vendor/hindsight-memory/scripts/lib/pending.py +199 -28
  32. package/vendor/hindsight-memory/scripts/reconcile_tail.py +1 -0
  33. package/vendor/hindsight-memory/scripts/retain.py +41 -1
  34. package/vendor/hindsight-memory/scripts/subagent_retain.py +1 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +23 -1
  36. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +5 -1
  37. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  38. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  39. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +325 -0
  40. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  41. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
  42. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
  43. package/vendor/hindsight-memory/settings.json +1 -1
  44. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -12,6 +12,8 @@ single deterministic id), 6 (watermark monotonicity + uuid-not-found), 7
12
12
  bound ENQUEUES the remainder).
13
13
  """
14
14
 
15
+ import contextlib
16
+ import io
15
17
  import json
16
18
  import os
17
19
  import shutil
@@ -41,12 +43,16 @@ class FakeDaemon:
41
43
 
42
44
  def __init__(self):
43
45
  self.docs = {} # document_id -> {content, metadata, async}
46
+ # switchroom: the observation_scopes kwarg each POST carried.
47
+ self.observation_scopes_seen = []
44
48
  self.posts = [] # [(document_id, async_processing)]
45
49
  self.fail = False
46
50
  self.drop_async = False
47
51
 
48
52
  def retain(self, bank_id, content, document_id="conversation", context=None,
49
- metadata=None, tags=None, timeout=15, async_processing=True):
53
+ metadata=None, tags=None, timeout=15, async_processing=True,
54
+ observation_scopes=None):
55
+ self.observation_scopes_seen.append(observation_scopes)
50
56
  self.posts.append((document_id, async_processing))
51
57
  if self.fail:
52
58
  raise RuntimeError("simulated daemon failure")
@@ -390,6 +396,159 @@ class TestSidechainSkip(DurabilityTestBase):
390
396
  self.assertIn(f"user turn {i}", blob)
391
397
 
392
398
 
399
+ class TestObservationScopes(DurabilityTestBase):
400
+ """switchroom — the per-row observation scope on the LIVE retain paths.
401
+
402
+ Asserts the OUTCOME on the wire: what scope the Stop hook, the boot
403
+ reconciler, and the pending-queue drain actually POST with. Each of those
404
+ is a separate hand-enumerated kwarg list, so each needs its own pin — a
405
+ miss on any one silently drops that path back to per-tag scopes.
406
+ """
407
+
408
+ def _hook(self, session="scopesess", n=5):
409
+ tpath = os.path.join(self.transcripts, f"{session}.jsonl")
410
+ _write_transcript(tpath, n, session_prefix=session)
411
+ return {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
412
+
413
+ def _set_scope(self, value):
414
+ os.environ["HINDSIGHT_OBSERVATION_SCOPES"] = value
415
+ self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPES", None)
416
+
417
+ # -- default: nothing changes -------------------------------------------
418
+ def test_unconfigured_stop_retain_posts_no_scope(self):
419
+ hook = self._hook("plainsess")
420
+ with mock.patch("retain.increment_turn_count", return_value=3), \
421
+ mock.patch("sys.stdin", _stdin(hook)):
422
+ retain.main()
423
+ self.assertTrue(self.daemon.observation_scopes_seen)
424
+ self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
425
+
426
+ # -- Stop hook (retain.py) ----------------------------------------------
427
+ def test_configured_stop_retain_posts_the_scope(self):
428
+ self._set_scope("shared")
429
+ hook = self._hook("livesess")
430
+ with mock.patch("retain.increment_turn_count", return_value=3), \
431
+ mock.patch("sys.stdin", _stdin(hook)):
432
+ retain.main()
433
+ self.assertTrue(self.daemon.observation_scopes_seen)
434
+ self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
435
+
436
+ # -- boot reconciler (reconcile_tail.py) --------------------------------
437
+ def test_configured_boot_reconcile_posts_the_scope(self):
438
+ self._set_scope("shared")
439
+ hook = self._hook("reconsess")
440
+ reconcile_tail.reconcile(self._config(), hook_input=hook)
441
+ self.assertTrue(self.daemon.observation_scopes_seen)
442
+ self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
443
+
444
+ # -- durability: the scope survives the pending queue --------------------
445
+ def test_scope_survives_failure_enqueue_and_drain(self):
446
+ self._set_scope("shared")
447
+ hook = self._hook("failsess")
448
+
449
+ # The daemon refuses, so the Stop retain is ENQUEUED rather than landed.
450
+ self.daemon.fail = True
451
+ with mock.patch("retain.increment_turn_count", return_value=3), \
452
+ mock.patch("sys.stdin", _stdin(hook)):
453
+ retain.main()
454
+ entries = self._pending_entries()
455
+ self.assertEqual(len(entries), 1)
456
+ _path, entry = entries[0]
457
+ # The queued entry carries the scope, so the drain does not have to
458
+ # re-resolve config that may have changed since.
459
+ self.assertEqual(entry["observation_scopes"], "shared")
460
+
461
+ # Drain it hours later: it lands in the SAME scope.
462
+ import drain_pending
463
+ seen = []
464
+
465
+ class _Client:
466
+ def __init__(self, *a, **kw):
467
+ pass
468
+
469
+ def retain(self, **kwargs):
470
+ seen.append(kwargs.get("observation_scopes"))
471
+ return {"ok": True}
472
+
473
+ with mock.patch.object(drain_pending, "HindsightClient", _Client):
474
+ drain_pending._retry_one(entry, timeout=15)
475
+ self.assertEqual(seen, ["shared"])
476
+
477
+ # -- a typo must never destroy a memory ---------------------------------
478
+ #
479
+ # These are the regression tests for the worst bug the validation
480
+ # introduced. `build_retain_payload` used to RAISE on an off-list value.
481
+ # It is called from `run_retain`, which `retain.main` calls WITHOUT a
482
+ # try/except before its `pending_enqueue` — so the raise unwound past the
483
+ # enqueue: nothing POSTed, nothing queued, watermark not advanced, the turn
484
+ # gone. `session_end.py` caught it but had no payload to queue, and
485
+ # `session_start.py` swallowed the same raise into `debug_log` and aborted
486
+ # the reconcile loop that would have re-derived it. Every producer lost its
487
+ # memory outright, permanently and silently, for as long as the bad value
488
+ # sat in the config — switchroom #3244's shape, which this feature cites.
489
+ #
490
+ # A misconfigured scope is recoverable. A deleted turn is not. So a bad
491
+ # value degrades to the PRE-FEATURE behaviour (no field on the wire, the
492
+ # engine's own default scope) and is shouted about on stderr.
493
+
494
+ def test_a_typo_does_not_destroy_the_live_stop_retain(self):
495
+ self._set_scope("shred") # not "shared"
496
+ hook = self._hook("typolivesess")
497
+ err = io.StringIO()
498
+ with mock.patch("retain.increment_turn_count", return_value=3), \
499
+ mock.patch("sys.stdin", _stdin(hook)), \
500
+ contextlib.redirect_stderr(err):
501
+ retain.main()
502
+
503
+ # OUTCOME 1: the memory LANDED. Not "an exception was caught" — the
504
+ # turns are in the bank.
505
+ # (chunked mode with retainEveryNTurns=3 slices the last 3 turns)
506
+ blob = self.daemon.content_blob()
507
+ self.assertIn("user turn 4", blob)
508
+ # OUTCOME 2: at the engine's own default scope (field omitted), which
509
+ # is exactly what an unconfigured agent does.
510
+ self.assertTrue(self.daemon.observation_scopes_seen)
511
+ self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
512
+ # OUTCOME 3: and it was loud. A silent downgrade would be the original
513
+ # "typo ignored forever" defect.
514
+ self.assertIn("shred", err.getvalue())
515
+ self.assertIn("observation_scopes", err.getvalue())
516
+
517
+ def test_a_typo_does_not_destroy_a_FAILED_stop_retain(self):
518
+ # The reviewer's reproduction: with the raise in place this enqueued 0
519
+ # entries and left the watermark unmoved, so the turn was lost for good.
520
+ self._set_scope("shred")
521
+ hook = self._hook("typofailsess")
522
+
523
+ self.daemon.fail = True
524
+ with mock.patch("retain.increment_turn_count", return_value=3), \
525
+ mock.patch("sys.stdin", _stdin(hook)), \
526
+ contextlib.redirect_stderr(io.StringIO()):
527
+ retain.main()
528
+
529
+ # OUTCOME: the payload was still built and still ENQUEUED, so the next
530
+ # SessionStart drain replays it. This is the assertion that matters —
531
+ # the memory has a durable on-disk copy.
532
+ entries = self._pending_entries()
533
+ self.assertEqual(len(entries), 1)
534
+ _path, entry = entries[0]
535
+ self.assertIn("user turn 4", entry["content"])
536
+ self.assertIsNone(entry["observation_scopes"])
537
+
538
+ def test_a_typo_does_not_destroy_the_boot_reconcile(self):
539
+ # session_start.py swallows a reconcile raise into debug_log AND aborts
540
+ # the loop on the first bad payload, so the recovery path that would
541
+ # re-derive lost turns did nothing either.
542
+ self._set_scope("shred")
543
+ hook = self._hook("typoreconsess")
544
+ with contextlib.redirect_stderr(io.StringIO()):
545
+ reconcile_tail.reconcile(self._config(), hook_input=hook)
546
+ blob = self.daemon.content_blob()
547
+ self.assertIn("user turn 0", blob)
548
+ self.assertTrue(self.daemon.observation_scopes_seen)
549
+ self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
550
+
551
+
393
552
  def _stdin(obj):
394
553
  import io
395
554
  return io.StringIO(json.dumps(obj))
@@ -289,7 +289,7 @@ class BoundedRead(unittest.TestCase):
289
289
 
290
290
 
291
291
  class RunSubagentRetain(unittest.TestCase):
292
- def _run(self, hook_input):
292
+ def _run(self, hook_input, config_extra=None):
293
293
  captured = {}
294
294
 
295
295
  class _Client:
@@ -300,7 +300,8 @@ class RunSubagentRetain(unittest.TestCase):
300
300
  captured.update(kwargs)
301
301
  return {"ok": True}
302
302
 
303
- with mock.patch("subagent_retain.load_config", return_value=dict(CONFIG)), \
303
+ config = dict(CONFIG, **(config_extra or {}))
304
+ with mock.patch("subagent_retain.load_config", return_value=config), \
304
305
  mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
305
306
  mock.patch("subagent_retain.ensure_bank_mission"), \
306
307
  mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
@@ -336,6 +337,43 @@ class RunSubagentRetain(unittest.TestCase):
336
337
  self.assertEqual(captured["context"], "claude-code-sidechain")
337
338
  self.assertEqual(captured["metadata"]["parent_session_id"], "parentsess")
338
339
 
340
+ def test_sidechain_retain_omits_the_scope_when_unconfigured(self):
341
+ # switchroom — default behaviour must be byte-identical: the sidechain
342
+ # POST carries no scope, so the engine default stands.
343
+ with tempfile.TemporaryDirectory() as d:
344
+ sc = os.path.join(d, "agent-af5.jsonl")
345
+ _write_sidechain(sc, 8, chars_per_msg=400)
346
+ hook_input = {
347
+ "session_id": "parentsess",
348
+ "agent_id": "af5",
349
+ "agent_transcript_path": sc,
350
+ "transcript_path": os.path.join(d, "parentsess.jsonl"),
351
+ "cwd": d,
352
+ }
353
+ result, captured = self._run(hook_input)
354
+ self.assertEqual(result["status"], "ok")
355
+ self.assertIsNone(captured["observation_scopes"])
356
+
357
+ def test_sidechain_retain_posts_the_configured_scope(self):
358
+ # switchroom — sidechain retains are their own hand-enumerated kwarg
359
+ # list; without this pin they would silently keep per-tag scopes while
360
+ # the parent session's retains moved to the shared one.
361
+ with tempfile.TemporaryDirectory() as d:
362
+ sc = os.path.join(d, "agent-af5.jsonl")
363
+ _write_sidechain(sc, 8, chars_per_msg=400)
364
+ hook_input = {
365
+ "session_id": "parentsess",
366
+ "agent_id": "af5",
367
+ "agent_transcript_path": sc,
368
+ "transcript_path": os.path.join(d, "parentsess.jsonl"),
369
+ "cwd": d,
370
+ }
371
+ result, captured = self._run(
372
+ hook_input, config_extra={"observationScopes": "shared"}
373
+ )
374
+ self.assertEqual(result["status"], "ok")
375
+ self.assertEqual(captured["observation_scopes"], "shared")
376
+
339
377
  def test_document_id_is_stable_across_refires(self):
340
378
  with tempfile.TemporaryDirectory() as d:
341
379
  sc = os.path.join(d, "agent-af5.jsonl")
@@ -2,7 +2,7 @@
2
2
  "hindsightApiUrl": "",
3
3
  "bankId": "claude_code",
4
4
  "bankMission": "You are a Claude Code AI assistant. Focus on technical discussions, decisions, and context relevant to the user's projects.",
5
- "retainMission": "Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nA TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this\ncandidate a file path, a command/process/agent/session id, a temp directory, or\nthe location where some output was written? If yes, drop it — it is transcript\nexhaust, not memory.\n\nNEVER extract:\n- Tool results verbatim or paraphrased. Concretely, never produce a fact whose\n text resembles any of these: \"File created successfully at /path/to/file\",\n \"A background command with ID bctz4yskm is running, and its output will be\n written to /tmp/...\", \"Async agent a745598ba84e71df1 was launched successfully\n and is running in the background\", \"User executed a Bash command to sleep for\n 200 seconds\", \"The assistant used grep to locate 'truncateSync' in src/foo.ts\".\n- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the\n assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- In-flight workflow/process narration (a sub-task started, paused, or is still\n running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.\n- Slash commands the user typed and their effects (e.g. \"User issued /clear to\n reset assistant state\").\n- Hindsight's own errors, retries, backlogs, or internal state — the memory\n system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- Transient state (unread counts, build status, what is running right now) unless\n the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If\nnothing durable remains, return an empty facts list.",
5
+ "retainMission": "Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nA TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this\ncandidate a file path, a command/process/agent/session id, a temp directory, or\nthe location where some output was written? If yes, drop it — it is transcript\nexhaust, not memory.\n\nNEVER extract:\n- Tool results verbatim or paraphrased. Concretely, never produce a fact whose\n text resembles any of these: \"File created successfully at /path/to/file\",\n \"A background command with ID bctz4yskm is running, and its output will be\n written to /tmp/...\", \"Async agent a745598ba84e71df1 was launched successfully\n and is running in the background\", \"User executed a Bash command to sleep for\n 200 seconds\", \"The assistant used grep to locate 'truncateSync' in src/foo.ts\".\n- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the\n assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- In-flight workflow/process narration (a sub-task started, paused, or is still\n running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.\n- Slash commands the user typed and their effects (e.g. \"User issued /clear to\n reset assistant state\").\n- Hindsight's own errors, retries, backlogs, or internal state — the memory\n system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- Volatile state written as a timeless assertion. A version, count, size,\n backlog, status, or any \"X is running Y\" / \"X is at Y\" / \"X is currently Y\"\n claim is true only at the instant it was said. Concretely, never produce a\n fact whose text resembles any of these: \"Switchroom fleet is running image\n version v0.18.19\", \"The switchroom repo is at /path/to/fleet, version\n v0.19.5\", \"Bank overlord has 43155 pending consolidations\", \"The build is\n currently green\". If the claim is worth keeping, put the date INSIDE the\n fact text (\"As of 2026-07-19 the fleet was running v0.18.19\"); if you\n cannot date it, drop it. An undated one is recalled forever as though it\n were still true, which is worse than not remembering it at all.\n- Transient state (unread counts, build status, what is running right now) unless\n the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If\nnothing durable remains, return an empty facts list.",
6
6
  "autoRecall": true,
7
7
  "autoRetain": true,
8
8
  "retainMode": "full-session",
@@ -104,9 +104,18 @@ class TestRecallHook:
104
104
  raise OSError("connection refused")
105
105
 
106
106
  hook_input = make_hook_input(prompt="What is my project about?")
107
- # Should not raise — graceful degradation
107
+ # Should not raise — graceful degradation. Since switchroom #3619 that
108
+ # degradation is no longer SILENT: an unreachable OWN bank emits the
109
+ # degraded-recall disclosure, so the agent says "I could not check"
110
+ # rather than asserting there is no prior context. This case asserted
111
+ # the pre-#3619 silence and had been RED on main ever since. Nothing
112
+ # noticed because no CI job ran this file; #3688 wired the suite into
113
+ # ci-tests-python.yml, which is what surfaced it. The behaviour itself
114
+ # is pinned CI-side by scripts/tests/test_recall_degraded_notice.py.
108
115
  output = _run_hook("recall", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
109
- assert output.strip() == ""
116
+ data = json.loads(output)
117
+ assert data["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
118
+ assert "DEGRADED" in data["hookSpecificOutput"]["additionalContext"]
110
119
 
111
120
  def test_output_format_matches_claude_code_spec(self, monkeypatch, tmp_path):
112
121
  memory = make_memory("User prefers Python")