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
@@ -172,6 +172,15 @@ DEFAULTS = {
172
172
  "retainContext": "claude-code",
173
173
  "retainTags": [],
174
174
  "retainMetadata": {},
175
+ # Switchroom-local: per-row Hindsight `observation_scopes` on every retain.
176
+ # `"shared"` makes consolidation write this item's observations into ONE
177
+ # global untagged scope instead of a scope per tag — what a set of agents
178
+ # pooling one bank needs. `None` (the default) omits the field from the
179
+ # wire body entirely, leaving the engine's own default in force. Set by
180
+ # start.sh from `agents.<name>.memory.observation_scopes` (cascading
181
+ # through `defaults.memory.observation_scopes`) via
182
+ # HINDSIGHT_OBSERVATION_SCOPES, exported ONLY when the operator opted in.
183
+ "observationScopes": None,
175
184
  # Switchroom hindsight-leverage E2 / PR9 (#398) — lesson & anti-pattern
176
185
  # tagging at retain time. When on (default), build_retain_payload scans the
177
186
  # formatted transcript slice for explicit lesson / anti-pattern markers and
@@ -319,6 +328,11 @@ ENV_OVERRIDES = {
319
328
  "HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
320
329
  "HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
321
330
  "HINDSIGHT_RETAIN_MODE": ("retainMode", str),
331
+ # Switchroom-local: per-row observation scope on retains. Set by start.sh
332
+ # from agents.<name>.memory.observation_scopes (cascading through
333
+ # defaults.memory.observation_scopes) ONLY when the operator set it; unset
334
+ # leaves `observationScopes` None and the field off the wire entirely.
335
+ "HINDSIGHT_OBSERVATION_SCOPES": ("observationScopes", str),
322
336
  # Switchroom hindsight-leverage E2 / PR9 (#398) — lesson/anti-pattern tagging
323
337
  # + recall demotion toggles and overrides.
324
338
  "HINDSIGHT_LESSON_TAGGING": ("lessonTagging", bool),
@@ -422,6 +436,83 @@ ENV_OVERRIDES = {
422
436
  }
423
437
 
424
438
 
439
+ #: Switchroom-local: the `observation_scopes` values Hindsight accepts as a
440
+ #: bare string (`MemoryItem.observation_scopes`, typed
441
+ #: `Literal["per_tag","combined","all_combinations","shared"] | list[list[str]]
442
+ #: | None` server-side). The explicit list-of-lists tag matrix is deliberately
443
+ #: NOT exposed through switchroom config: unbounded, no safe fleet-wide
444
+ #: default, no caller needs it. Paired with `OBSERVATION_SCOPES` in
445
+ #: src/memory/observation-scopes.ts, which the zod enum reads — widening the
446
+ #: set means widening BOTH.
447
+ OBSERVATION_SCOPES_VALUES = ("per_tag", "combined", "all_combinations", "shared")
448
+
449
+
450
+ def classify_observation_scopes(config: dict):
451
+ """Classify ``observationScopes`` WITHOUT raising: ``(value, error)``.
452
+
453
+ Exactly one of the two is non-``None``:
454
+
455
+ * ``(None, None)`` — unset. Do not put the field on the wire at all;
456
+ the shipped default, byte-for-byte the pre-plumbing request body.
457
+ * ``(value, None)`` — a valid member of :data:`OBSERVATION_SCOPES_VALUES`.
458
+ * ``(None, reason)`` — an off-list or non-string value, with a
459
+ human-readable reason naming the accepted set.
460
+
461
+ THIS FUNCTION MUST NEVER RAISE, and callers on the retain path must never
462
+ turn its ``error`` into one. A bad scope is a misconfiguration; losing the
463
+ turn is data loss. Those are not the same severity and must not share a
464
+ failure mode — see ``retain.build_retain_payload`` for the consequence
465
+ chain (a raise there propagated out of ``run_retain`` and past
466
+ ``retain.main``'s ``pending_enqueue``, so the turn was never queued,
467
+ the watermark never advanced, and the boot reconciler swallowed the same
468
+ raise into ``debug_log`` — the memory was gone, permanently and silently).
469
+ That is switchroom #3244's shape, which this very feature cites.
470
+
471
+ An empty/whitespace-only value is treated as UNSET, matching the plugin's
472
+ existing "an empty export hands authority back to the config file" idiom
473
+ (see ``_cast_env``): an absent knob, not a typo'd one.
474
+ """
475
+ raw = config.get("observationScopes")
476
+ if raw is None:
477
+ return None, None
478
+ if not isinstance(raw, str):
479
+ return None, (
480
+ "observationScopes must be a string, one of "
481
+ f"{', '.join(OBSERVATION_SCOPES_VALUES)}; got {type(raw).__name__} ({raw!r}). "
482
+ "Set it via `memory.observation_scopes` in switchroom.yaml."
483
+ )
484
+ value = raw.strip()
485
+ if not value:
486
+ return None, None
487
+ if value not in OBSERVATION_SCOPES_VALUES:
488
+ return None, (
489
+ f"observationScopes={raw!r} is not a valid Hindsight observation scope. "
490
+ f"Accepted values: {', '.join(OBSERVATION_SCOPES_VALUES)}. "
491
+ "Set it via `memory.observation_scopes` in switchroom.yaml "
492
+ "(a typo there is rejected at `switchroom apply`)."
493
+ )
494
+ return value, None
495
+
496
+
497
+ def resolve_observation_scopes(config: dict):
498
+ """Strict form of :func:`classify_observation_scopes` — raises on a bad value.
499
+
500
+ ``None`` means "do not put the field on the wire at all".
501
+
502
+ Raises ``ValueError`` on any value outside
503
+ :data:`OBSERVATION_SCOPES_VALUES`. This is the VALIDATOR, for callers that
504
+ genuinely want to fail — a config check, a test, a hand-run script that
505
+ should stop before it writes anything. **It is deliberately NOT what the
506
+ retain path calls**: a retain must never be destroyed by a config typo, so
507
+ ``retain.build_retain_payload`` uses the non-raising classifier and shouts
508
+ instead. See ``classify_observation_scopes``.
509
+ """
510
+ value, error = classify_observation_scopes(config)
511
+ if error:
512
+ raise ValueError(error)
513
+ return value
514
+
515
+
425
516
  def _cast_env(value: str, typ):
426
517
  """Cast environment variable string to target type. Returns None on failure."""
427
518
  try:
@@ -41,12 +41,18 @@ Each entry is a JSON file ``<unix-ms>-<short-uuid>.json`` containing::
41
41
  "context": "<retainContext>",
42
42
  "metadata": {...},
43
43
  "tags": [...] or null,
44
+ "observation_scopes": "<scope>" or null,
44
45
  "failed_at": "<ISO-8601 UTC>",
45
46
  "error_class": "<exception class name>",
46
47
  "error_message": "<str(e)>",
47
48
  "attempt_count": 1
48
49
  }
49
50
 
51
+ ``observation_scopes`` is ABSENT from every entry queued by a build that
52
+ predates it, and those entries are on disk right now. Readers must use
53
+ ``entry.get("observation_scopes")``, never ``entry[...]`` — a KeyError in
54
+ ``drain_pending._retry_one`` would strand the last on-disk copy of a turn.
55
+
50
56
  The file is written via ``write tmp + rename`` so concurrent agents
51
57
  sharing ``$HOME`` (legacy installs) never observe a half-written entry.
52
58
 
@@ -556,6 +562,112 @@ def _dir_bytes(d: str, names) -> int:
556
562
  return total
557
563
 
558
564
 
565
+ def _append_ledger(line: str) -> None:
566
+ """Append one line to ``pending-evictions.log``, rotating at its cap.
567
+
568
+ Split out of ``_log_eviction`` (#3688 review R1-M2) so the OTHER way a
569
+ payload leaves this module for good — ``_trim_dir`` shedding an archived
570
+ copy — can be ledgered on exactly the same terms instead of leaving no
571
+ durable trace at all. Best-effort: a ledger that cannot be written *or
572
+ read back* must never take the caller down with it — hence the guard
573
+ catches ``ValueError`` as well as ``OSError`` (see there).
574
+
575
+ ROTATION PRIORITISES ``evicted=`` INSIDE THE SAME BUDGET, and that is a
576
+ direct consequence of sharing the file. Before #3688 every line here
577
+ was an eviction, so "keep the newest ``EVICTIONS_LOG_KEEP_LINES``
578
+ lines" and "keep the newest 2,000 evictions" were the same sentence.
579
+ They are not any more: a single ``collapse_duplicates`` run over the
580
+ measured 1,060-file backlog writes ~192 ``trimmed=`` lines, and a plain
581
+ tail-rotate would let that noise push real ``evicted=`` lines out of
582
+ the 7-day window ``switchroom doctor`` reads — i.e. adding
583
+ observability for the BENIGN channel would have removed it for the one
584
+ channel that means memory is actually gone.
585
+
586
+ So the keep window is filled evictions-first and only then topped up
587
+ with the newest remaining lines. The budget is unchanged at
588
+ ``EVICTIONS_LOG_KEEP_LINES`` lines TOTAL — a ledger of nothing but
589
+ evictions rotates exactly as it did before #3688 — and chronological
590
+ order is preserved because the selection is by index over the original
591
+ lines, which ``switchroom doctor``'s ``$1 >= cutoff`` awk depends on.
592
+ """
593
+ log = evictions_log_path()
594
+ try:
595
+ with open(log, "a", encoding="utf-8") as f:
596
+ print(line, file=f)
597
+ # Bounded, not append-forever. `switchroom doctor` windows this by
598
+ # timestamp so a single legitimate eviction can't turn the row red
599
+ # permanently, but the FILE still needs a ceiling of its own.
600
+ if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
601
+ with open(log, encoding="utf-8") as f:
602
+ lines = f.readlines()
603
+ evictions = [i for i, ln in enumerate(lines) if "evicted=" in ln]
604
+ others = [i for i, ln in enumerate(lines) if "evicted=" not in ln]
605
+ keep_idx = set(evictions[-EVICTIONS_LOG_KEEP_LINES:])
606
+ room = EVICTIONS_LOG_KEEP_LINES - len(keep_idx)
607
+ if room > 0:
608
+ keep_idx.update(others[-room:])
609
+ kept = [ln for i, ln in enumerate(lines) if i in keep_idx]
610
+ tmp = log + ".tmp"
611
+ with open(tmp, "w", encoding="utf-8") as f:
612
+ f.writelines(kept)
613
+ os.chmod(tmp, 0o600)
614
+ os.replace(tmp, log)
615
+ except (OSError, ValueError):
616
+ # ValueError, not just OSError, because BOTH codec errors are
617
+ # ValueError subclasses and neither is an OSError (#3688 re-review
618
+ # B1). The rotate READS the ledger back (``readlines()`` above) —
619
+ # something no revision before #3688 did — so a ledger holding one
620
+ # non-UTF-8 byte raises ``UnicodeDecodeError`` here, and a name
621
+ # carrying a surrogate raises ``UnicodeEncodeError`` at the
622
+ # ``print`` above. Under a bare ``except OSError`` both escape this
623
+ # "best-effort" guard: ``_trim_dir`` → ``_log_archive_trim`` → here
624
+ # runs inside ``archive_reconciled``/``enqueue``, so one corrupt
625
+ # ledger byte would take down every drain — on the sidecar,
626
+ # identically, every 900s forever. A ledger that cannot be written
627
+ # must never take the caller down with it, and that has to include
628
+ # the codec.
629
+ pass
630
+
631
+
632
+ def _log_archive_trim(
633
+ name: str, size: int, reason: str, archive: str, depth: int, nbytes: int
634
+ ) -> None:
635
+ """Ledger ONE archived copy shed by ``_trim_dir``.
636
+
637
+ Until #3688 review R1-M2 a trim was stderr-only: an entry could enter
638
+ ``pending-duplicate/`` (or ``pending-evicted/``, or
639
+ ``pending-reconciled/``) and then be deleted from it with no durable
640
+ record anywhere, while an *eviction* — the other end of the same
641
+ payload's life — has had ``pending-evictions.log`` since #3599. stderr
642
+ is gone with the process; the ledger is what an operator can still read
643
+ a week later. Same file, because it answers one question ("what left
644
+ this agent's queue, and when").
645
+
646
+ THE FIRST TOKEN IS DELIBERATELY ``trimmed=``, NOT ``evicted=``.
647
+ ``switchroom doctor``'s probe counts eviction ledger lines with
648
+ ``awk '$1 >= cutoff && /evicted=/'`` (src/cli/doctor.ts, see
649
+ ``buildPendingRetainsProbeScript``), and that count FAILS the
650
+ pending-retains row. A trim is not an eviction — nothing was shed from
651
+ the live queue and, for ``pending-duplicate/``, a byte-identical copy is
652
+ still queued — so a trim must not be able to turn that row red. No field
653
+ on this line may ever be formatted such that ``evicted=`` appears in it;
654
+ ``ArchiveTrimLedgerTest`` pins that.
655
+ """
656
+ _append_ledger(
657
+ "%s trimmed=%s bytes=%d archive=%s reason=archive-%s "
658
+ "archive_depth=%d archive_bytes=%d"
659
+ % (
660
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
661
+ name,
662
+ size,
663
+ archive,
664
+ reason,
665
+ depth,
666
+ nbytes,
667
+ )
668
+ )
669
+
670
+
559
671
  def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
560
672
  """Keep ``a`` under its count/byte caps, deleting OLDEST first.
561
673
 
@@ -610,6 +722,21 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
610
722
  ``pending-evicted/`` is a different story again — see ``_evict_to_fit``:
611
723
  an entry only reaches it through the ledgered eviction path, and under
612
724
  sustained ENOSPC it may not reach it at all.
725
+
726
+ EVERY DROP IS LEDGERED, AND ONLY A DROP (#3688 review R1-M2, tightened
727
+ by re-review B2). One ``trimmed=… archive=…`` line per shed copy goes
728
+ to ``pending-evictions.log`` via ``_log_archive_trim``, so the trim
729
+ horizon above is observable after the fact and not only in a stderr
730
+ stream nobody kept. That line is deliberately NOT counted as an
731
+ eviction by doctor — see ``_log_archive_trim``.
732
+
733
+ A victim whose ``os.remove`` FAILS (EACCES, read-only mount) is neither
734
+ ledgered nor counted nor named on stderr: it is still on disk, so a
735
+ ``trimmed=`` line for it would be a false claim of deletion in exactly
736
+ the record an operator consults after suspected loss, and the returned
737
+ count is what callers report as "copies shed". ``TrimDirBoundaryTest``
738
+ pins that a failed remove yields ``0``, no ledger line, and every file
739
+ still present.
613
740
  """
614
741
  # `.json` ONLY, and that is load-bearing beyond "skip stray files": a
615
742
  # `.dead` marker is named `<entry>.json.dead`, so it can never be
@@ -622,14 +749,35 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
622
749
  except OSError:
623
750
  return 0
624
751
  dropped = []
625
- while names and (
626
- len(names) > max_entries or _dir_bytes(a, names) > max_bytes
627
- ):
752
+ label = os.path.basename(a.rstrip("/"))
753
+ nbytes = _dir_bytes(a, names)
754
+ while names and (len(names) > max_entries or nbytes > max_bytes):
755
+ reason = "count" if len(names) > max_entries else "bytes"
756
+ victim = names.pop(0)
757
+ vpath = os.path.join(a, victim)
628
758
  try:
629
- os.remove(os.path.join(a, names[0]))
759
+ vsize = os.path.getsize(vpath)
630
760
  except OSError:
631
- pass
632
- dropped.append(names.pop(0))
761
+ vsize = 0
762
+ try:
763
+ os.remove(vpath)
764
+ except OSError:
765
+ # The copy is still on disk, so the running total would drift.
766
+ # Re-measure rather than assume — this preserves the original
767
+ # recompute-every-iteration semantics on the one path where a
768
+ # decrement would be wrong (EACCES / read-only mount).
769
+ nbytes = _dir_bytes(a, names)
770
+ # NOT counted and NOT ledgered (#3688 re-review B2). The ledger
771
+ # is the forensic record an operator reads AFTER suspected data
772
+ # loss; a `trimmed=` line for a copy still sitting on disk sends
773
+ # them hunting a payload that never left, and the returned count
774
+ # (and the stderr line below) would name files that were not
775
+ # dropped. A ledger that claims deletions which never happened
776
+ # is worse than no ledger.
777
+ continue
778
+ nbytes -= vsize
779
+ dropped.append(victim)
780
+ _log_archive_trim(victim, vsize, reason, label, len(names), nbytes)
633
781
  if dropped:
634
782
  shown = ", ".join(dropped[:10])
635
783
  if len(dropped) > 10:
@@ -736,6 +884,47 @@ def archive_duplicate(path: str) -> Optional[str]:
736
884
  return dest
737
885
 
738
886
 
887
+ def _attempt_count(entry: dict) -> int:
888
+ """How many retries this copy has BURNED, for survivor selection.
889
+
890
+ Not ``int(entry.get("attempt_count", 1) or 1)`` (#3688 review R1-L3).
891
+ That expression had two defects, both latent rather than reachable
892
+ today — ``_build_entry`` seeds ``1`` and ``update_attempt`` only ever
893
+ increments — which is exactly why they need pinning rather than
894
+ ignoring: nothing would have caught them turning real.
895
+
896
+ * ``0 or 1`` is ``1``, so a NEVER-ATTEMPTED copy scored the same as one
897
+ that had already burned an attempt, and lost the tie-break to it if
898
+ it happened to be the newer file. That is backwards from the rule
899
+ ``collapse_duplicates`` documents ("the copy with the most retries
900
+ left before ``MAX_ATTEMPTS`` wins") — a 0 must beat a 1.
901
+ * ``int("many")`` RAISES. A single semantically-malformed entry — valid
902
+ JSON, so ``iter_entries`` hands it over rather than quarantining it —
903
+ took the whole collapse pass down with it, and with it (before the
904
+ guard in ``drain_pending._drain_backlog_impl``) the whole drain.
905
+ Unparseable now scores ``MAX_ATTEMPTS``: we cannot tell how many
906
+ attempts it has left, so it is never PREFERRED as the survivor, but
907
+ it is never the reason a collapse fails either. Losing it costs
908
+ nothing anyway — every copy in a group is byte-identical, and the
909
+ loser is archived, not deleted.
910
+
911
+ A missing or ``None`` count reads as ``1``, matching what
912
+ ``_build_entry`` writes, so an entry from an older build that predates
913
+ the field is treated as freshly queued rather than exhausted.
914
+ """
915
+ raw = entry.get("attempt_count", 1)
916
+ if raw is None:
917
+ return 1
918
+ # bool is an int subclass; True would silently score 1. Neither True nor
919
+ # False is an attempt count, so both take the unparseable branch.
920
+ if isinstance(raw, bool):
921
+ return MAX_ATTEMPTS
922
+ try:
923
+ return int(raw)
924
+ except (TypeError, ValueError):
925
+ return MAX_ATTEMPTS
926
+
927
+
739
928
  def collapse_duplicates() -> int:
740
929
  """Collapse entries sharing ``(bank_id, part_position, sha256(content))``.
741
930
 
@@ -754,7 +943,8 @@ def collapse_duplicates() -> int:
754
943
  memory; the one with the most attempts left is the one most likely to
755
944
  get there before ``MAX_ATTEMPTS`` promotes it to ``.dead``. Picking the
756
945
  oldest outright would systematically keep the most-attempted copy —
757
- exactly backwards.
946
+ exactly backwards. ``_attempt_count`` defines "lowest", including what
947
+ a ``0`` and what a malformed count are worth.
758
948
 
759
949
  An entry whose key is ``None`` (no ``content``) is never grouped: its
760
950
  identity cannot be established, so it is always kept.
@@ -773,10 +963,7 @@ def collapse_duplicates() -> int:
773
963
  for key, members in groups.items():
774
964
  if len(members) < 2:
775
965
  continue
776
- survivor = min(
777
- members,
778
- key=lambda m: (int(m[2].get("attempt_count", 1) or 1), m[0]),
779
- )
966
+ survivor = min(members, key=lambda m: (_attempt_count(m[2]), m[0]))
780
967
  for member in members:
781
968
  if member is survivor:
782
969
  continue
@@ -1047,23 +1234,7 @@ def _log_eviction(name: str, size: int, reason: str, depth: int, nbytes: int) ->
1047
1234
  depth,
1048
1235
  nbytes,
1049
1236
  )
1050
- log = evictions_log_path()
1051
- try:
1052
- with open(log, "a", encoding="utf-8") as f:
1053
- print(line, file=f)
1054
- # Bounded, not append-forever. `switchroom doctor` windows this by
1055
- # timestamp so a single legitimate eviction can't turn the row red
1056
- # permanently, but the FILE still needs a ceiling of its own.
1057
- if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
1058
- with open(log, encoding="utf-8") as f:
1059
- kept = f.readlines()[-(EVICTIONS_LOG_KEEP_LINES):]
1060
- tmp = log + ".tmp"
1061
- with open(tmp, "w", encoding="utf-8") as f:
1062
- f.writelines(kept)
1063
- os.chmod(tmp, 0o600)
1064
- os.replace(tmp, log)
1065
- except OSError:
1066
- pass
1237
+ _append_ledger(line)
1067
1238
  print(
1068
1239
  "[Hindsight] pending-retains FULL - evicted OLDEST entry to keep the "
1069
1240
  "newest memory: %s (%d bytes, %s; queue now %d entries / %d bytes). "
@@ -312,6 +312,7 @@ def _post_inline(
312
312
  tags=payload["tags"],
313
313
  timeout=15,
314
314
  async_processing=False,
315
+ observation_scopes=payload.get("observation_scopes"),
315
316
  )
316
317
  except Exception as e:
317
318
  debug_log(config, f"reconcile_tail: inline POST failed, enqueuing: {e}")
@@ -28,7 +28,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
28
  from lib import watermark
29
29
  from lib.bank import derive_bank_id, ensure_bank_mission
30
30
  from lib.client import HindsightClient
31
- from lib.config import debug_log, load_config
31
+ from lib.config import classify_observation_scopes, debug_log, load_config
32
32
  from lib.content import (
33
33
  prepare_retention_transcript,
34
34
  slice_last_turns_by_user_boundary,
@@ -266,6 +266,14 @@ def build_retain_payload(
266
266
 
267
267
  Returns ``{payload, document_id, message_count, last_uuid, ordered_uuids,
268
268
  transcript}`` or ``None`` when the slice formats to nothing.
269
+
270
+ NEVER raises on a bad ``observationScopes``. Every retain producer builds
271
+ its payload here, so this seam sees the typo — but it is also the seam the
272
+ memory itself is made at, and a config typo must not be able to destroy
273
+ one. An off-list value is dropped from the payload (so the engine's own
274
+ default stands, exactly as before this feature existed) and shouted about
275
+ on stderr; the memory is still built, still POSTed, still queued on
276
+ failure. See ``lib.config.classify_observation_scopes``.
269
277
  """
270
278
  retain_roles = config.get("retainRoles", ["user", "assistant"])
271
279
  include_tool_calls = config.get("retainToolCalls", True)
@@ -359,6 +367,36 @@ def build_retain_payload(
359
367
  except Exception:
360
368
  pass
361
369
 
370
+ # Per-row observation scope (switchroom). None unless the operator set
371
+ # memory.observation_scopes — and a None is dropped at the wire by
372
+ # HindsightClient._retain_one, so the default request body is unchanged.
373
+ # Carried ON THE PAYLOAD so it survives the pending-retains queue: a retain
374
+ # that fails and drains hours later must land in the SAME scope it would
375
+ # have landed in inline.
376
+ #
377
+ # CLASSIFIED, NOT VALIDATED. This is the one seam every retain producer
378
+ # funnels through, which makes it the tempting place to reject a typo — and
379
+ # the worst possible place to raise from. Raising here does not "fail the
380
+ # retain", it DELETES the turn: the exception propagates out of run_retain,
381
+ # past retain.main's pending_enqueue (so nothing is queued and the
382
+ # watermark never advances), session_end.py catches it with no payload to
383
+ # queue, and session_start.py's reconciler swallows it into debug_log and
384
+ # aborts the loop that would have re-derived it. Every producer loses its
385
+ # memory outright, silently, for as long as the bad value sits in the
386
+ # config — switchroom #3244's exact shape.
387
+ #
388
+ # So a bad value degrades to the PRE-FEATURE behaviour (field omitted, the
389
+ # engine's own default scope stands) and is shouted about on stderr. Wrong
390
+ # scope is recoverable; a lost turn is not.
391
+ scope, scope_error = classify_observation_scopes(config)
392
+ if scope_error:
393
+ print(
394
+ f"[Hindsight] observation_scopes IGNORED for this retain: {scope_error} "
395
+ "The memory is being retained at the engine's default scope rather "
396
+ "than dropped — fix the value, then `switchroom apply` and restart "
397
+ "the agent.",
398
+ file=sys.stderr,
399
+ )
362
400
  payload = {
363
401
  "api_url": api_url,
364
402
  "api_token": api_token,
@@ -368,6 +406,7 @@ def build_retain_payload(
368
406
  "context": config.get("retainContext", "claude-code"),
369
407
  "metadata": metadata,
370
408
  "tags": tags,
409
+ "observation_scopes": scope,
371
410
  }
372
411
  return {
373
412
  "payload": payload,
@@ -573,6 +612,7 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
573
612
  tags=payload["tags"],
574
613
  timeout=15,
575
614
  async_processing=False,
615
+ observation_scopes=payload.get("observation_scopes"),
576
616
  )
577
617
  except Exception as e:
578
618
  print(f"[Hindsight] Retain failed: {e}", file=sys.stderr)
@@ -443,6 +443,7 @@ def run_subagent_retain(hook_input: dict) -> dict:
443
443
  tags=payload["tags"],
444
444
  timeout=15,
445
445
  async_processing=False,
446
+ observation_scopes=payload.get("observation_scopes"),
446
447
  )
447
448
  except Exception as e:
448
449
  print(f"[Hindsight] Sidechain retain failed: {e}", file=sys.stderr)
@@ -35,6 +35,8 @@ class FakeDaemon:
35
35
 
36
36
  def __init__(self):
37
37
  self.docs = {} # document_id -> {content, ...}
38
+ # switchroom: the observation_scopes kwarg each POST carried.
39
+ self.observation_scopes_seen = []
38
40
  self.posts = [] # [(document_id, async_processing)]
39
41
  self.mission_patches = [] # set_bank_mission calls (must stay empty)
40
42
  self.fail = False
@@ -42,7 +44,9 @@ class FakeDaemon:
42
44
  self._inflight = 0
43
45
 
44
46
  def retain(self, bank_id, content, document_id="conversation", context=None,
45
- metadata=None, tags=None, timeout=15, async_processing=True):
47
+ metadata=None, tags=None, timeout=15, async_processing=True,
48
+ observation_scopes=None):
49
+ self.observation_scopes_seen.append(observation_scopes)
46
50
  self._inflight += 1
47
51
  self.max_inflight_seen = max(self.max_inflight_seen, self._inflight)
48
52
  try:
@@ -167,6 +171,24 @@ class TestBackfill(BackfillTestBase):
167
171
  self.assertTrue(self.daemon.posts)
168
172
  self.assertTrue(all(async_flag is False for _, async_flag in self.daemon.posts))
169
173
 
174
+ # -- switchroom: per-row observation scope on the backfill path ---------
175
+ def test_backfill_omits_the_scope_when_unconfigured(self):
176
+ self._transcript("clerk", "sess-plain", 4)
177
+ bf.Backfill(self._config(), commit=True, delay_ms=0).run()
178
+ self.assertTrue(self.daemon.observation_scopes_seen)
179
+ self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
180
+
181
+ def test_backfill_posts_the_configured_scope(self):
182
+ # The backfill enumerates its own retain kwargs; a miss here would
183
+ # scatter every recovered historical slice into per-tag scopes while
184
+ # live retains pooled into the shared one.
185
+ os.environ["HINDSIGHT_OBSERVATION_SCOPES"] = "shared"
186
+ self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPES", None)
187
+ self._transcript("clerk", "sess-scoped", 4)
188
+ bf.Backfill(self._config(), commit=True, delay_ms=0).run()
189
+ self.assertTrue(self.daemon.observation_scopes_seen)
190
+ self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
191
+
170
192
  # -- Dedups against a pre-existing document with the same deterministic id
171
193
  def test_dedups_against_preexisting_document_id(self):
172
194
  path = self._transcript("clerk", "sess-dup", 3)
@@ -53,12 +53,16 @@ class FakeDaemon:
53
53
  def __init__(self):
54
54
  self.docs = {}
55
55
  self.posts = []
56
+ # switchroom: the observation_scopes kwarg each POST carried.
57
+ self.observation_scopes_seen = []
56
58
  self.max_inflight_seen = 0
57
59
  self._inflight = 0
58
60
  self.membership_error = False
59
61
 
60
62
  def retain(self, bank_id, content, document_id="conversation", context=None,
61
- metadata=None, tags=None, timeout=15, async_processing=True):
63
+ metadata=None, tags=None, timeout=15, async_processing=True,
64
+ observation_scopes=None):
65
+ self.observation_scopes_seen.append(observation_scopes)
62
66
  self._inflight += 1
63
67
  self.max_inflight_seen = max(self.max_inflight_seen, self._inflight)
64
68
  try: