switchroom 0.16.46 → 0.17.0

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 (109) hide show
  1. package/dist/agent-scheduler/index.js +83 -81
  2. package/dist/auth-broker/index.js +104 -88
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -15
  5. package/dist/cli/notion-write-pretool.mjs +85 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1720 -1392
  8. package/dist/cli/ui/index.html +84 -12
  9. package/dist/host-control/main.js +209 -173
  10. package/dist/vault/approvals/kernel-server.js +86 -83
  11. package/dist/vault/broker/server.js +284 -139
  12. package/package.json +3 -3
  13. package/profiles/_base/cron-session.sh.hbs +1 -1
  14. package/profiles/_base/start.sh.hbs +54 -3
  15. package/skills/switchroom-architecture/telegram.md +8 -15
  16. package/skills/switchroom-cli/SKILL.md +4 -5
  17. package/skills/telegram-test-harness/SKILL.md +1 -1
  18. package/telegram-plugin/README.md +18 -29
  19. package/telegram-plugin/bridge/bridge.ts +1 -41
  20. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  21. package/telegram-plugin/dist/bridge/bridge.js +120 -155
  22. package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
  23. package/telegram-plugin/dist/server.js +168 -203
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +566 -631
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  33. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  34. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  36. package/telegram-plugin/history.ts +5 -0
  37. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  38. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  39. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  40. package/telegram-plugin/registry/turns-schema.ts +65 -1
  41. package/telegram-plugin/session-tail.ts +26 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -2
  43. package/telegram-plugin/status-query-telemetry.ts +100 -0
  44. package/telegram-plugin/stream-reply-handler.ts +15 -16
  45. package/telegram-plugin/subagent-watcher.ts +182 -30
  46. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  47. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  48. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  49. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  50. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  51. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  52. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  53. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  54. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  55. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  56. package/telegram-plugin/tests/model-command.test.ts +54 -1
  57. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  58. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  59. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  60. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  61. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  62. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  63. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  64. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  65. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  66. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  67. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  68. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  69. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  70. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  71. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  72. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  73. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  75. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  76. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  77. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  78. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  79. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  80. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  81. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  82. package/telegram-plugin/tool-activity-summary.ts +19 -0
  83. package/telegram-plugin/turn-flush-safety.ts +16 -1
  84. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  85. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  86. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  87. package/telegram-plugin/worker-activity-feed.ts +75 -15
  88. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  89. package/vendor/hindsight-memory/README.md +5 -0
  90. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  91. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  92. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  93. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  94. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  95. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  96. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  97. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  98. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  99. package/vendor/hindsight-memory/settings.json +4 -0
  100. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  101. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  102. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  103. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  104. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  105. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  106. package/telegram-plugin/silent-reply.ts +0 -58
  107. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  108. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  109. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -40,6 +40,12 @@ DEFAULTS = {
40
40
  "recallContextTurns": 1,
41
41
  "recallMaxQueryChars": 800,
42
42
  "recallRoles": ["user", "assistant"],
43
+ # Upstream 962140eef — optional recall tag filters passed through to the
44
+ # recall API, plus per-additional-bank overrides keyed by bank ID.
45
+ "recallTags": [],
46
+ "recallTagsMatch": "any",
47
+ "recallTagGroups": None,
48
+ "recallAdditionalBankFilters": {},
43
49
  "recallPromptPreamble": (
44
50
  "Relevant memories from past conversations (prioritize recent when "
45
51
  "conflicting). Only use memories that are directly useful to continue "
@@ -63,6 +69,12 @@ DEFAULTS = {
63
69
  "daemonIdleTimeout": 0,
64
70
  "embedVersion": "latest",
65
71
  "embedPackagePath": None,
72
+ # Upstream 55ef70679 — optional global HTTP request timeout override
73
+ # (seconds). None = keep each call's own default. NOTE: switchroom's
74
+ # recall.py deliberately does NOT wire this override into its client —
75
+ # recall carries its own 8s hook-budget timeout (see recall.py). This
76
+ # mainly benefits retain's 15s timeout on slow/loaded servers.
77
+ "requestTimeoutSeconds": None,
66
78
  # Bank
67
79
  "bankId": None,
68
80
  "bankIdPrefix": "",
@@ -109,8 +121,16 @@ ENV_OVERRIDES = {
109
121
  "HINDSIGHT_RECALL_SKIP_TRIVIAL": ("recallSkipTrivial", bool),
110
122
  "HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
111
123
  "HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
124
+ # Upstream 962140eef — recall tag filters. The tags env var accepts JSON
125
+ # or a comma-separated list; the others must be JSON.
126
+ "HINDSIGHT_RECALL_TAGS": ("recallTags", list),
127
+ "HINDSIGHT_RECALL_TAGS_MATCH": ("recallTagsMatch", str),
128
+ "HINDSIGHT_RECALL_TAG_GROUPS": ("recallTagGroups", dict),
129
+ "HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": ("recallAdditionalBankFilters", dict),
112
130
  "HINDSIGHT_API_PORT": ("apiPort", int),
113
131
  "HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),
132
+ # Upstream 55ef70679 — global request timeout override.
133
+ "HINDSIGHT_REQUEST_TIMEOUT_SECONDS": ("requestTimeoutSeconds", int),
114
134
  "HINDSIGHT_EMBED_VERSION": ("embedVersion", str),
115
135
  "HINDSIGHT_EMBED_PACKAGE_PATH": ("embedPackagePath", str),
116
136
  "HINDSIGHT_DYNAMIC_BANK_ID": ("dynamicBankId", bool),
@@ -131,8 +151,27 @@ def _cast_env(value: str, typ):
131
151
  if typ is float:
132
152
  return float(value)
133
153
  if typ is list:
134
- # Comma-separated list of trimmed, non-empty strings.
135
- return [t.strip() for t in value.split(",") if t.strip()]
154
+ # JSON list first (upstream 962140eef). A value that parses as
155
+ # JSON but is NOT a list (e.g. `42`, `"x"`, `{}`) is a config
156
+ # mistake, not a comma-separated string — return None so the
157
+ # default is kept (matches upstream; fail-open). Only values
158
+ # that don't parse as JSON at all take the comma-split path.
159
+ try:
160
+ parsed = json.loads(value)
161
+ except ValueError:
162
+ if value.lstrip().startswith(("[", "{")):
163
+ # Looks like intended JSON but doesn't parse —
164
+ # malformed config, not a comma list. Keep default.
165
+ return None
166
+ # Comma-separated → list of trimmed, non-empty strings.
167
+ return [t.strip() for t in value.split(",") if t.strip()]
168
+ return parsed if isinstance(parsed, list) else None
169
+ if typ is dict:
170
+ # JSON only (dict or list accepted — tag_groups may be a list).
171
+ parsed = json.loads(value)
172
+ if isinstance(parsed, (dict, list)):
173
+ return parsed
174
+ return None
136
175
  return value
137
176
  except (ValueError, AttributeError):
138
177
  return None
@@ -221,10 +221,13 @@ def format_memories(results: list) -> str:
221
221
  def format_current_time() -> str:
222
222
  """Format current UTC time for recall context.
223
223
 
224
+ The "UTC" suffix is explicit so client LLMs do not misread the
225
+ value as local time when reasoning about wall-clock context.
226
+
224
227
  Port of: formatCurrentTimeForRecall() in index.js
225
228
  """
226
229
  now = datetime.now(timezone.utc)
227
- return now.strftime("%Y-%m-%d %H:%M")
230
+ return now.strftime("%Y-%m-%d %H:%M UTC")
228
231
 
229
232
 
230
233
  # ---------------------------------------------------------------------------
@@ -71,8 +71,17 @@ def _is_embed_available(config: dict) -> bool:
71
71
  return shutil.which("uvx") is not None or shutil.which("hindsight-embed") is not None
72
72
 
73
73
 
74
- def _check_health(base_url: str, timeout: int = 2) -> bool:
75
- """Quick health check against a Hindsight server."""
74
+ def _check_health(base_url: str, timeout: int = 10) -> bool:
75
+ """Quick health check against a Hindsight server.
76
+
77
+ Default timeout is 10s (matching the recall hook budget): under load an
78
+ alive-but-busy daemon mid fact-extraction may not answer /health within a
79
+ couple of seconds. A too-short timeout yields a false negative, so
80
+ get_api_url() falls through to _ensure_daemon_running() ->
81
+ `hindsight-embed daemon start`, whose _clear_port() then SIGTERMs the
82
+ live daemon -- a restart/kill loop. A 10s budget lets a busy daemon
83
+ respond before it is declared dead.
84
+ """
76
85
  try:
77
86
  url = f"{base_url.rstrip('/')}/health"
78
87
  req = urllib.request.Request(url, method="GET", headers={"User-Agent": USER_AGENT})
@@ -229,6 +229,33 @@ def _resolve_sender_bank(
229
229
  return additional_banks
230
230
 
231
231
 
232
+ def _tag_filter_sig(
233
+ recall_tags,
234
+ tags_match,
235
+ tag_groups,
236
+ additional_bank_filters,
237
+ ) -> str:
238
+ """Stable fingerprint of the recall tag-filter configuration
239
+ (upstream 962140eef) for cache keying. Tag filters change what the
240
+ recall API returns for an identical query, so they MUST be part of
241
+ the cache key — otherwise a config change (or per-bank filter edit)
242
+ within the TTL window would serve stale, differently-filtered
243
+ results. Empty/default filters collapse to "" so pre-existing cache
244
+ behaviour (and keys) are unchanged when the feature is unused."""
245
+ if not (recall_tags or tag_groups or additional_bank_filters):
246
+ return ""
247
+ try:
248
+ return json.dumps(
249
+ [recall_tags, tags_match, tag_groups, additional_bank_filters],
250
+ sort_keys=True,
251
+ separators=(",", ":"),
252
+ )
253
+ except (TypeError, ValueError):
254
+ # Unserializable config — fall back to repr; stable within a
255
+ # process and still distinguishes filtered from unfiltered.
256
+ return repr([recall_tags, tags_match, tag_groups, additional_bank_filters])
257
+
258
+
232
259
  def _cache_key(
233
260
  session_id: str,
234
261
  prompt: str,
@@ -236,6 +263,7 @@ def _cache_key(
236
263
  extra_banks: list,
237
264
  active_thread_id: str | None = None,
238
265
  active_sender: str | None = None,
266
+ tag_filter_sig: str = "",
239
267
  ) -> str:
240
268
  """Stable hash for cache keying. Session_id is included so a new
241
269
  session always misses, regardless of the TTL setting. Extra banks
@@ -259,6 +287,9 @@ def _cache_key(
259
287
  ",".join(sorted(extra_banks or [])),
260
288
  active_thread_id or "",
261
289
  active_sender or "",
290
+ # Upstream 962140eef port: tag filters shape the result set, so
291
+ # they are part of the key (see _tag_filter_sig). "" when unused.
292
+ tag_filter_sig or "",
262
293
  ]
263
294
  payload = "\x1f".join(parts)
264
295
  return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -703,11 +734,32 @@ def main():
703
734
  additional_banks,
704
735
  )
705
736
 
737
+ # Upstream 962140eef — optional recall tag filters. Resolved BEFORE the
738
+ # cache check so the tag-filter fingerprint is part of the cache key
739
+ # (filters change the result set for an identical query). Per-bank
740
+ # overrides in recallAdditionalBankFilters apply to any additional bank —
741
+ # including sender banks appended by _resolve_sender_bank above.
742
+ recall_tags = config.get("recallTags") or None
743
+ tag_groups = config.get("recallTagGroups") or None
744
+ tags_match = config.get("recallTagsMatch") if recall_tags or tag_groups else None
745
+ additional_bank_filters = config.get("recallAdditionalBankFilters") or {}
746
+ if not isinstance(additional_bank_filters, dict):
747
+ additional_bank_filters = {}
748
+ tag_filter_sig = _tag_filter_sig(recall_tags, tags_match, tag_groups, additional_bank_filters)
749
+
706
750
  # Switchroom #424 phase 4.1 — cache check BEFORE any HTTP traffic.
707
751
  # Whole-session-scoped, opt-in via HINDSIGHT_RECALL_CACHE_TTL_SECS.
708
752
  cache_ttl = _cache_ttl_secs()
709
753
  cache_key = (
710
- _cache_key(session_id, prompt, bank_id, additional_banks, active_thread_id, active_sender)
754
+ _cache_key(
755
+ session_id,
756
+ prompt,
757
+ bank_id,
758
+ additional_banks,
759
+ active_thread_id,
760
+ active_sender,
761
+ tag_filter_sig,
762
+ )
711
763
  if cache_ttl > 0
712
764
  else ""
713
765
  )
@@ -787,6 +839,11 @@ def main():
787
839
  max_tokens=config.get("recallMaxTokens", 1024),
788
840
  budget=config.get("recallBudget", "mid"),
789
841
  types=config.get("recallTypes"),
842
+ # Upstream 962140eef — optional tag filters (resolved above the
843
+ # cache check; part of the cache key).
844
+ tags=recall_tags,
845
+ tags_match=tags_match,
846
+ tag_groups=tag_groups,
790
847
  # 8s in-script timeout leaves 4s headroom inside the 12s
791
848
  # UserPromptSubmit hook ceiling (see hooks.json:20) for cache
792
849
  # write + block formatting. Tightened from 10s in switchroom
@@ -809,6 +866,19 @@ def main():
809
866
  # cache key reflects every bank queried; reuse that local instead of
810
867
  # re-reading config.
811
868
  for extra_bank_id in additional_banks:
869
+ # Upstream 962140eef — per-bank tag-filter overrides; fall back to
870
+ # the global filters when the bank has no entry. Applies uniformly
871
+ # to config-listed banks and sender banks appended by
872
+ # _resolve_sender_bank (both flow through `additional_banks`).
873
+ extra_filter = additional_bank_filters.get(extra_bank_id, {})
874
+ if not isinstance(extra_filter, dict):
875
+ extra_filter = {}
876
+ extra_tags = extra_filter.get("recallTags", recall_tags) or None
877
+ extra_tag_groups = extra_filter.get("recallTagGroups", tag_groups) or None
878
+ extra_tags_match = extra_filter.get(
879
+ "recallTagsMatch",
880
+ tags_match if extra_tags or extra_tag_groups else None,
881
+ )
812
882
  try:
813
883
  extra_response = client.recall(
814
884
  bank_id=extra_bank_id,
@@ -816,6 +886,9 @@ def main():
816
886
  max_tokens=config.get("recallMaxTokens", 1024),
817
887
  budget=config.get("recallBudget", "mid"),
818
888
  types=config.get("recallTypes"),
889
+ tags=extra_tags,
890
+ tags_match=extra_tags_match,
891
+ tag_groups=extra_tag_groups,
819
892
  # 8s in-script timeout leaves 4s headroom inside the 12s
820
893
  # UserPromptSubmit hook ceiling (see hooks.json:20) for cache
821
894
  # write + block formatting. Tightened from 10s in switchroom
@@ -156,7 +156,14 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
156
156
 
157
157
  api_token = config.get("hindsightApiToken")
158
158
  try:
159
- client = HindsightClient(api_url, api_token)
159
+ # Upstream 55ef70679 — honor the optional requestTimeoutSeconds
160
+ # override (retain runs outside the recall hook budget, so a longer
161
+ # timeout is safe here; recall.py deliberately omits this).
162
+ client = HindsightClient(
163
+ api_url,
164
+ api_token,
165
+ request_timeout_override=config.get("requestTimeoutSeconds"),
166
+ )
160
167
  except ValueError as e:
161
168
  print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
162
169
  return {"status": "failed", "error": e, "payload": None}
@@ -0,0 +1,111 @@
1
+ """Unit tests for config env casting (`lib.config._cast_env`) and the
2
+ client's request-timeout override clamp (`HindsightClient._resolve_timeout`).
3
+
4
+ Follow-ups from the #2816 review punch list:
5
+ - list cast: a value that parses as JSON but is NOT a list (e.g. `42`)
6
+ must return None (keep default, fail-open) instead of falling through
7
+ to comma-split and producing a junk one-element list. Malformed
8
+ intended-JSON (`[1,2`) also keeps the default; plain comma strings
9
+ still split.
10
+ - timeout override: zero/negative env values are clamped to >= 1 instead
11
+ of passing straight through to urlopen.
12
+
13
+ Stdlib-only.
14
+ """
15
+
16
+ import os
17
+ import sys
18
+ import unittest
19
+
20
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
21
+ if SCRIPTS_DIR not in sys.path:
22
+ sys.path.insert(0, SCRIPTS_DIR)
23
+
24
+ from lib.config import _cast_env # noqa: E402
25
+ from lib.client import HindsightClient # noqa: E402
26
+
27
+
28
+ class CastEnvListTests(unittest.TestCase):
29
+ def test_valid_json_list(self):
30
+ self.assertEqual(_cast_env('["a", "b"]', list), ["a", "b"])
31
+
32
+ def test_empty_json_list(self):
33
+ self.assertEqual(_cast_env("[]", list), [])
34
+
35
+ def test_comma_string_splits(self):
36
+ self.assertEqual(_cast_env("a, b ,c", list), ["a", "b", "c"])
37
+
38
+ def test_comma_string_drops_empty_tokens(self):
39
+ self.assertEqual(_cast_env("a,,b,", list), ["a", "b"])
40
+
41
+ def test_single_bare_string(self):
42
+ # Not valid JSON, no commas → one-element list.
43
+ self.assertEqual(_cast_env("solo", list), ["solo"])
44
+
45
+ def test_json_non_list_scalar_returns_none(self):
46
+ # Parses as JSON int — not a list, not a comma string. Default kept.
47
+ self.assertIsNone(_cast_env("42", list))
48
+
49
+ def test_json_non_list_object_returns_none(self):
50
+ self.assertIsNone(_cast_env('{"a": 1}', list))
51
+
52
+ def test_json_string_returns_none(self):
53
+ # A JSON-quoted string is valid JSON but not a list.
54
+ self.assertIsNone(_cast_env('"tag"', list))
55
+
56
+ def test_malformed_json_array_returns_none(self):
57
+ # Looks like intended JSON but doesn't parse → default kept,
58
+ # NOT comma-split into junk like ['["a"', '"b"'].
59
+ self.assertIsNone(_cast_env('["a", "b"', list))
60
+
61
+ def test_malformed_json_object_returns_none(self):
62
+ self.assertIsNone(_cast_env('{"a": ', list))
63
+
64
+
65
+ class CastEnvOtherTypesTests(unittest.TestCase):
66
+ """Guard the neighbours the list change must not disturb."""
67
+
68
+ def test_int_valid(self):
69
+ self.assertEqual(_cast_env("30", int), 30)
70
+
71
+ def test_int_invalid_returns_none(self):
72
+ self.assertIsNone(_cast_env("thirty", int))
73
+
74
+ def test_bool_true_variants(self):
75
+ for v in ("true", "1", "yes", "TRUE"):
76
+ self.assertTrue(_cast_env(v, bool), v)
77
+
78
+ def test_bool_false(self):
79
+ self.assertFalse(_cast_env("false", bool))
80
+
81
+ def test_dict_valid(self):
82
+ self.assertEqual(_cast_env('{"a": 1}', dict), {"a": 1})
83
+
84
+ def test_dict_non_container_returns_none(self):
85
+ self.assertIsNone(_cast_env("42", dict))
86
+
87
+
88
+ class ResolveTimeoutTests(unittest.TestCase):
89
+ URL = "http://127.0.0.1:9999"
90
+
91
+ def _client(self, override):
92
+ return HindsightClient(self.URL, request_timeout_override=override)
93
+
94
+ def test_no_override_uses_caller_timeout(self):
95
+ self.assertEqual(self._client(None)._resolve_timeout(30), 30)
96
+
97
+ def test_valid_override_wins(self):
98
+ self.assertEqual(self._client(15)._resolve_timeout(30), 15)
99
+
100
+ def test_zero_override_clamped_to_one(self):
101
+ self.assertEqual(self._client(0)._resolve_timeout(30), 1)
102
+
103
+ def test_negative_override_clamped_to_one(self):
104
+ self.assertEqual(self._client(-5)._resolve_timeout(30), 1)
105
+
106
+ def test_one_passes_through(self):
107
+ self.assertEqual(self._client(1)._resolve_timeout(30), 1)
108
+
109
+
110
+ if __name__ == "__main__":
111
+ unittest.main()
@@ -55,13 +55,35 @@ class _FakeClient:
55
55
  self._memories = memories if memories is not None else []
56
56
  self._recall_exc = recall_exc
57
57
  self._list_exc = list_exc
58
+ # One entry per recall() call — lets tests assert the tag-filter
59
+ # kwargs (upstream 962140eef) that main() passed per bank.
60
+ self.recall_calls = []
58
61
 
59
62
  def list_directives(self, bank_id, active_only=True, timeout=2):
60
63
  if self._list_exc is not None:
61
64
  raise self._list_exc
62
65
  return {"items": list(self._directives)}
63
66
 
64
- def recall(self, bank_id, query, max_tokens=1024, budget="mid", types=None, timeout=10):
67
+ def recall(
68
+ self,
69
+ bank_id,
70
+ query,
71
+ max_tokens=1024,
72
+ budget="mid",
73
+ types=None,
74
+ tags=None,
75
+ tags_match=None,
76
+ tag_groups=None,
77
+ timeout=10,
78
+ ):
79
+ self.recall_calls.append(
80
+ {
81
+ "bank_id": bank_id,
82
+ "tags": tags,
83
+ "tags_match": tags_match,
84
+ "tag_groups": tag_groups,
85
+ }
86
+ )
65
87
  if self._recall_exc is not None:
66
88
  raise self._recall_exc
67
89
  return {"results": list(self._memories)}
@@ -617,5 +639,67 @@ class OverlapGateIntegrationTests(unittest.TestCase):
617
639
  self.assertIsNone(ctx)
618
640
 
619
641
 
642
+ class RecallTagFilterIntegrationTests(unittest.TestCase):
643
+ """Upstream 962140eef port — tag filters flow through main() to each
644
+ per-bank recall call, composed with our additional-banks routing."""
645
+
646
+ def test_global_tags_passed_to_primary_bank(self):
647
+ client = _FakeClient(memories=[_memory("a fact")])
648
+ _run_main_with(
649
+ client,
650
+ config_extra={
651
+ "recallTags": ["memory_type:rule"],
652
+ "recallTagsMatch": "any_strict",
653
+ },
654
+ )
655
+ self.assertEqual(client.recall_calls[0]["tags"], ["memory_type:rule"])
656
+ self.assertEqual(client.recall_calls[0]["tags_match"], "any_strict")
657
+
658
+ def test_no_tags_match_sent_without_tags_or_groups(self):
659
+ client = _FakeClient(memories=[_memory("a fact")])
660
+ _run_main_with(client, config_extra={"recallTagsMatch": "all"})
661
+ self.assertIsNone(client.recall_calls[0]["tags"])
662
+ self.assertIsNone(client.recall_calls[0]["tags_match"])
663
+ self.assertIsNone(client.recall_calls[0]["tag_groups"])
664
+
665
+ def test_per_bank_filter_overrides_global_for_additional_bank(self):
666
+ client = _FakeClient(memories=[_memory("a fact")])
667
+ _run_main_with(
668
+ client,
669
+ config_extra={
670
+ "recallAdditionalBanks": ["shared-bank"],
671
+ "recallTags": ["tech_stack:supabase"],
672
+ "recallTagsMatch": "any",
673
+ "recallAdditionalBankFilters": {
674
+ "shared-bank": {
675
+ "recallTags": ["memory_type:rule"],
676
+ "recallTagsMatch": "all_strict",
677
+ }
678
+ },
679
+ },
680
+ )
681
+ primary, extra = client.recall_calls[0], client.recall_calls[1]
682
+ self.assertEqual(primary["bank_id"], "test-bank")
683
+ self.assertEqual(primary["tags"], ["tech_stack:supabase"])
684
+ self.assertEqual(primary["tags_match"], "any")
685
+ self.assertEqual(extra["bank_id"], "shared-bank")
686
+ self.assertEqual(extra["tags"], ["memory_type:rule"])
687
+ self.assertEqual(extra["tags_match"], "all_strict")
688
+
689
+ def test_additional_bank_without_override_inherits_global(self):
690
+ client = _FakeClient(memories=[_memory("a fact")])
691
+ _run_main_with(
692
+ client,
693
+ config_extra={
694
+ "recallAdditionalBanks": ["shared-bank"],
695
+ "recallTags": ["memory_type:rule"],
696
+ },
697
+ )
698
+ extra = client.recall_calls[1]
699
+ self.assertEqual(extra["tags"], ["memory_type:rule"])
700
+ # Global tags_match defaults are only sent when filters are active.
701
+ self.assertEqual(extra["tags_match"], None)
702
+
703
+
620
704
  if __name__ == "__main__":
621
705
  unittest.main()
@@ -0,0 +1,107 @@
1
+ """Unit tests for the recall tag-filter port (upstream 962140eef).
2
+
3
+ Covers the switchroom-specific composition points that upstream's own tests
4
+ cannot: the tag-filter fingerprint (`_tag_filter_sig`) and its inclusion in
5
+ the recall cache key (`_cache_key`). Tag filters change what the recall API
6
+ returns for an identical query, so a filter change within the cache TTL must
7
+ produce a cache MISS — otherwise stale, differently-filtered results would
8
+ be served.
9
+
10
+ Stdlib-only.
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ import unittest
16
+
17
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
18
+ if SCRIPTS_DIR not in sys.path:
19
+ sys.path.insert(0, SCRIPTS_DIR)
20
+
21
+ import recall # noqa: E402
22
+
23
+
24
+ class TagFilterSigTests(unittest.TestCase):
25
+ def test_empty_filters_collapse_to_empty_string(self):
26
+ # Backward-compat: unused feature must not perturb existing keys.
27
+ self.assertEqual(recall._tag_filter_sig(None, None, None, {}), "")
28
+ self.assertEqual(recall._tag_filter_sig([], None, None, {}), "")
29
+
30
+ def test_tags_produce_nonempty_sig(self):
31
+ self.assertNotEqual(recall._tag_filter_sig(["memory_type:rule"], "any", None, {}), "")
32
+
33
+ def test_tag_groups_alone_produce_nonempty_sig(self):
34
+ groups = [{"op": "all", "tags": ["a", "b"]}]
35
+ self.assertNotEqual(recall._tag_filter_sig(None, "any", groups, {}), "")
36
+
37
+ def test_bank_filters_alone_produce_nonempty_sig(self):
38
+ filters = {"profile-bank": {"recallTags": ["memory_type:rule"]}}
39
+ self.assertNotEqual(recall._tag_filter_sig(None, None, None, filters), "")
40
+
41
+ def test_sig_is_deterministic(self):
42
+ a = recall._tag_filter_sig(["t1"], "all", None, {"b": {"recallTags": ["x"]}})
43
+ b = recall._tag_filter_sig(["t1"], "all", None, {"b": {"recallTags": ["x"]}})
44
+ self.assertEqual(a, b)
45
+
46
+ def test_sig_stable_across_dict_key_order(self):
47
+ f1 = {"a": {"recallTags": ["x"]}, "b": {"recallTags": ["y"]}}
48
+ f2 = {"b": {"recallTags": ["y"]}, "a": {"recallTags": ["x"]}}
49
+ self.assertEqual(
50
+ recall._tag_filter_sig(["t"], "any", None, f1),
51
+ recall._tag_filter_sig(["t"], "any", None, f2),
52
+ )
53
+
54
+ def test_different_tags_different_sig(self):
55
+ self.assertNotEqual(
56
+ recall._tag_filter_sig(["memory_type:rule"], "any", None, {}),
57
+ recall._tag_filter_sig(["memory_type:fact"], "any", None, {}),
58
+ )
59
+
60
+ def test_different_match_mode_different_sig(self):
61
+ self.assertNotEqual(
62
+ recall._tag_filter_sig(["t"], "any", None, {}),
63
+ recall._tag_filter_sig(["t"], "all_strict", None, {}),
64
+ )
65
+
66
+ def test_unserializable_falls_back_to_repr(self):
67
+ # A pathological config value must not raise; it still yields a
68
+ # non-empty signature distinguishing it from "no filters".
69
+ sig = recall._tag_filter_sig([object()], "any", None, {})
70
+ self.assertTrue(sig)
71
+
72
+
73
+ class CacheKeyTagFilterTests(unittest.TestCase):
74
+ """The tag-filter fingerprint must be part of the recall cache key."""
75
+
76
+ ARGS = ("s1", "what are the rules", "clerk", ["profile"], "42", "ken")
77
+
78
+ def test_no_filters_matches_legacy_key(self):
79
+ # Default arg == explicit "" — pre-feature cache keys are unchanged.
80
+ legacy = recall._cache_key(*self.ARGS)
81
+ explicit = recall._cache_key(*self.ARGS, "")
82
+ self.assertEqual(legacy, explicit)
83
+
84
+ def test_filters_change_the_key(self):
85
+ sig = recall._tag_filter_sig(["memory_type:rule"], "any", None, {})
86
+ self.assertNotEqual(recall._cache_key(*self.ARGS), recall._cache_key(*self.ARGS, sig))
87
+
88
+ def test_different_filters_different_keys(self):
89
+ sig_a = recall._tag_filter_sig(["memory_type:rule"], "any", None, {})
90
+ sig_b = recall._tag_filter_sig(["memory_type:fact"], "any", None, {})
91
+ self.assertNotEqual(recall._cache_key(*self.ARGS, sig_a), recall._cache_key(*self.ARGS, sig_b))
92
+
93
+ def test_per_bank_filter_change_changes_key(self):
94
+ # Editing only recallAdditionalBankFilters (e.g. for a sender bank)
95
+ # must also invalidate the cache.
96
+ sig_a = recall._tag_filter_sig(["t"], "any", None, {"profile": {"recallTags": ["x"]}})
97
+ sig_b = recall._tag_filter_sig(["t"], "any", None, {"profile": {"recallTags": ["y"]}})
98
+ self.assertNotEqual(recall._cache_key(*self.ARGS, sig_a), recall._cache_key(*self.ARGS, sig_b))
99
+
100
+ def test_same_filters_same_key(self):
101
+ sig1 = recall._tag_filter_sig(["t"], "all", None, {})
102
+ sig2 = recall._tag_filter_sig(["t"], "all", None, {})
103
+ self.assertEqual(recall._cache_key(*self.ARGS, sig1), recall._cache_key(*self.ARGS, sig2))
104
+
105
+
106
+ if __name__ == "__main__":
107
+ unittest.main()
@@ -13,6 +13,10 @@
13
13
  "recallContextTurns": 1,
14
14
  "recallMaxQueryChars": 800,
15
15
  "recallRoles": ["user", "assistant"],
16
+ "recallTags": [],
17
+ "recallTagsMatch": "any",
18
+ "recallTagGroups": null,
19
+ "recallAdditionalBankFilters": {},
16
20
  "recallPromptPreamble": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:",
17
21
  "retainRoles": ["user", "assistant"],
18
22
  "retainEveryNTurns": 10,