switchroom 0.16.47 → 0.17.1

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 (117) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +24 -8
  3. package/dist/cli/drive-write-pretool.mjs +0 -5
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +1359 -1030
  6. package/dist/cli/ui/index.html +84 -12
  7. package/dist/host-control/main.js +53 -17
  8. package/dist/vault/approvals/kernel-server.js +4 -1
  9. package/dist/vault/broker/server.js +201 -56
  10. package/package.json +3 -3
  11. package/profiles/_base/cron-session.sh.hbs +1 -1
  12. package/profiles/_base/start.sh.hbs +54 -3
  13. package/skills/switchroom-architecture/telegram.md +8 -15
  14. package/skills/switchroom-cli/SKILL.md +4 -5
  15. package/skills/telegram-test-harness/SKILL.md +1 -1
  16. package/telegram-plugin/README.md +18 -29
  17. package/telegram-plugin/bridge/bridge.ts +1 -41
  18. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  19. package/telegram-plugin/dist/bridge/bridge.js +8 -43
  20. package/telegram-plugin/dist/gateway/gateway.js +842 -832
  21. package/telegram-plugin/dist/server.js +8 -43
  22. package/telegram-plugin/format.ts +119 -17
  23. package/telegram-plugin/gateway/approvals-commands.ts +6 -2
  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 +535 -627
  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/ms365-write-approval.test.ts +13 -0
  33. package/telegram-plugin/gateway/ms365-write-approval.ts +5 -1
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  35. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  36. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  37. package/telegram-plugin/gateway/vault-request-access-card.ts +5 -1
  38. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  39. package/telegram-plugin/history.ts +5 -0
  40. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  41. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  42. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  43. package/telegram-plugin/registry/turns-schema.ts +65 -1
  44. package/telegram-plugin/session-tail.ts +26 -4
  45. package/telegram-plugin/slot-banner-driver.ts +42 -2
  46. package/telegram-plugin/status-query-telemetry.ts +100 -0
  47. package/telegram-plugin/stream-reply-handler.ts +15 -16
  48. package/telegram-plugin/subagent-watcher.ts +182 -30
  49. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  50. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  51. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  52. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  53. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  54. package/telegram-plugin/tests/format-consistency.test.ts +79 -0
  55. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  56. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  57. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  58. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  59. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  60. package/telegram-plugin/tests/model-command.test.ts +54 -1
  61. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  62. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  63. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  64. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  65. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  66. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  68. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  69. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  70. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  71. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  72. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  73. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  74. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  75. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  76. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  77. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  78. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  79. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  80. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  81. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  82. package/telegram-plugin/tests/vault-request-access-card.test.ts +17 -0
  83. package/telegram-plugin/tests/welcome-text.test.ts +64 -0
  84. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  85. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  86. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  87. package/telegram-plugin/tool-activity-summary.ts +19 -0
  88. package/telegram-plugin/turn-flush-safety.ts +16 -1
  89. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  90. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  91. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  92. package/telegram-plugin/welcome-text.ts +13 -9
  93. package/telegram-plugin/worker-activity-feed.ts +75 -15
  94. package/vendor/hindsight-memory/CHANGELOG.md +66 -0
  95. package/vendor/hindsight-memory/README.md +5 -0
  96. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  97. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  98. package/vendor/hindsight-memory/scripts/lib/content.py +43 -4
  99. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  100. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  101. package/vendor/hindsight-memory/scripts/retain.py +79 -11
  102. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  103. package/vendor/hindsight-memory/scripts/tests/test_recall_context_slice.py +126 -0
  104. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  105. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  106. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +261 -0
  107. package/vendor/hindsight-memory/settings.json +4 -0
  108. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  109. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  110. package/vendor/hindsight-memory/tests/test_content.py +123 -0
  111. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  112. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  113. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  114. package/telegram-plugin/silent-reply.ts +0 -58
  115. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  116. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  117. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -2,6 +2,72 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Changed (switchroom divergence)
6
+
7
+ - **retain.py: decouple chunked window-slicing from the `retainEveryNTurns > 1`
8
+ throttle** (switchroom Phase 6b). Previously the chunked sliding-window only
9
+ applied when `retainEveryNTurns > 1`; with `retainEveryNTurns=1` (switchroom
10
+ sets this in `scaffold.ts` for every-turn crash durability) chunked mode fell
11
+ through to full-session and re-consolidated the entire accumulated transcript
12
+ on every Stop fire. Window selection is now extracted into a pure
13
+ `select_retain_window()` helper and slices a window of
14
+ `max(retainEveryNTurns, 1) + retainOverlapTurns` turns whenever
15
+ `retainMode == "chunked"`, independent of the throttle. The throttle-skip
16
+ logic (`retain_every_n > 1` firing cadence) is unchanged, so `> 1` behaviour
17
+ and the full-session default are equivalent. This is a deliberate switchroom
18
+ divergence from pristine vendor and is a **candidate to upstream to
19
+ vectorize-io/hindsight** — decoupling *what* to retain from *whether* to fire
20
+ this turn is a general improvement, not switchroom-specific.
21
+
22
+ - **content.py: `slice_last_turns_by_user_boundary()` counts genuine HUMAN
23
+ turns only** (switchroom Phase 6b, adversarial-review fix). Claude Code emits
24
+ tool results as `role="user"` messages whose content is a list of
25
+ `tool_result` blocks. The boundary counter treated every `role="user"`
26
+ message as a turn, so on a tool-heavy turn (≥N sequential tool rounds) a
27
+ fixed-size retain window filled with `tool_result` messages and pushed the
28
+ actual human message OUTSIDE the window — silently dropping the fact from
29
+ that fire and every later fire (whose window starts even further away), so it
30
+ was never retained; on restart the fact was gone. A message whose content is
31
+ entirely `tool_result` blocks is now skipped as a boundary
32
+ (`_is_tool_result_only_user_message`), so "window = N turns" means N *human*
33
+ turns regardless of tool volume. Affects both the retain window-slice and the
34
+ recall context-slice (both want N human turns). **Candidate to upstream** —
35
+ the same silent-loss bug exists in vendor's own `retainEveryNTurns > 1`
36
+ chunked path. NOTE: switchroom never ran chunked before Phase 6b, so this
37
+ changes no previously-exercised switchroom behaviour.
38
+
39
+ - **retain.py: SessionEnd `force=True` widens chunked mode to a full-session
40
+ sweep** (switchroom Phase 6b, belt-and-braces). Per-turn fires still slice
41
+ the window; the single forced retain at SessionEnd
42
+ (`session_end.py` → `run_retain(force=True)`) now retains the whole session
43
+ in chunked mode, guaranteeing a graceful shutdown always flushes everything
44
+ even if per-turn windowing had an edge. Costs one full sweep per session (at
45
+ end), not per turn.
46
+
47
+ ### Ported from upstream (vectorize-io/hindsight, `hindsight-integrations/claude-code/`)
48
+
49
+ - `c5a61db2b` — raise `_check_health` default timeout 2s→10s in
50
+ `scripts/lib/daemon.py` to stop the busy-daemon restart/kill loop
51
+ (applied clean; codex-integration hunk not applicable).
52
+ - `3d6c2ba8b` — label "Current time" as UTC in the recall context block
53
+ (`lib/content.py:format_current_time`), so client LLMs in non-UTC
54
+ timezones don't misread the timestamp as local time.
55
+ - `962140eef` — recall tag filters: `recallTags`, `recallTagsMatch`,
56
+ `recallTagGroups`, plus per-additional-bank overrides via
57
+ `recallAdditionalBankFilters`. Hand-ported into the switchroom recall.py
58
+ rewrite: filters compose with sender-bank routing (per-bank overrides
59
+ apply to sender banks too) and are part of the recall cache key
60
+ (`_tag_filter_sig`) so a filter change can't serve stale cached results.
61
+ Note: because the key now joins an extra part (empty string when filters
62
+ are unused), every cache key rotates ONCE across this upgrade boundary —
63
+ the first recall per session after upgrading is a cache miss. Within a
64
+ version, keys are unchanged as long as filters stay unused.
65
+ - `55ef70679` — optional `requestTimeoutSeconds` /
66
+ `HINDSIGHT_REQUEST_TIMEOUT_SECONDS` global request-timeout override in
67
+ `HindsightClient` (adapted to our `_request`). Wired into retain.py only;
68
+ recall.py deliberately keeps its own 8s hook-budget timeout. Upstream's
69
+ mcp_server.py hunks skipped (not vendored).
70
+
5
71
  ### Added
6
72
 
7
73
  - `{user_id}` template variable for `retainTags` and `retainMetadata`, resolved
@@ -186,6 +186,11 @@ Auto-recall runs on every user prompt. It queries Hindsight for relevant memorie
186
186
  | `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; higher values give more context but may dilute the query. |
187
187
  | `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. |
188
188
  | `recallRoles` | — | `["user", "assistant"]` | Which message roles to include when building the recall query from prior turns. |
189
+ | `recallTags` | `HINDSIGHT_RECALL_TAGS` | `[]` | Optional tags to pass to the recall API, such as `["memory_type:rule"]`. The env var accepts JSON or a comma-separated list. |
190
+ | `recallTagsMatch` | `HINDSIGHT_RECALL_TAGS_MATCH` | `"any"` | Tag matching mode used with `recallTags` or `recallTagGroups`: `"any"`, `"all"`, `"any_strict"`, or `"all_strict"`. |
191
+ | `recallTagGroups` | `HINDSIGHT_RECALL_TAG_GROUPS` | `null` | Optional compound tag filter passed through to the recall API. The env var must be JSON. |
192
+ | `recallAdditionalBankFilters` | `HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS` | `{}` | Optional per-bank tag filter overrides for banks listed in `recallAdditionalBanks`, keyed by bank ID. Each value may set `recallTags`, `recallTagsMatch`, and `recallTagGroups`. The env var must be JSON. |
193
+ | `requestTimeoutSeconds` | `HINDSIGHT_REQUEST_TIMEOUT_SECONDS` | unset | Optional global HTTP request timeout override (seconds) for retain and API calls. The recall hook keeps its own 8s hook-budget timeout regardless. |
189
194
  | `recallPromptPreamble` | — | built-in string | Text placed above the recalled memories in the injected context block. Customize this to change how Claude interprets the memories. |
190
195
 
191
196
  ---
@@ -44,9 +44,29 @@ def _validate_api_url(url: str) -> str:
44
44
  class HindsightClient:
45
45
  """HTTP client for the Hindsight API."""
46
46
 
47
- def __init__(self, api_url: str, api_token: Optional[str] = None):
47
+ def __init__(
48
+ self,
49
+ api_url: str,
50
+ api_token: Optional[str] = None,
51
+ request_timeout_override: Optional[int] = None,
52
+ ):
48
53
  self.api_url = _validate_api_url(api_url)
49
54
  self.api_token = api_token
55
+ self.request_timeout_override = request_timeout_override
56
+
57
+ def _resolve_timeout(self, timeout: int) -> int:
58
+ """Return the override if configured, otherwise the caller's timeout.
59
+
60
+ Upstream 55ef70679. NOTE: recall.py deliberately does not pass the
61
+ override — its 8s timeout is a hook-budget invariant.
62
+
63
+ The override is clamped to >= 1: a zero/negative env value would
64
+ otherwise reach urlopen as a nonsensical timeout (0 fails every
65
+ request immediately), turning a config typo into a dead client.
66
+ """
67
+ if self.request_timeout_override is None:
68
+ return timeout
69
+ return max(1, self.request_timeout_override)
50
70
 
51
71
  def _headers(self) -> dict:
52
72
  headers = {
@@ -58,6 +78,7 @@ class HindsightClient:
58
78
  return headers
59
79
 
60
80
  def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
81
+ timeout = self._resolve_timeout(timeout)
61
82
  url = f"{self.api_url}{path}"
62
83
  data = json.dumps(body).encode() if body else None
63
84
  req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
@@ -100,6 +121,9 @@ class HindsightClient:
100
121
  max_tokens: int = 1024,
101
122
  budget: str = "mid",
102
123
  types: Optional[list] = None,
124
+ tags: Optional[list] = None,
125
+ tags_match: Optional[str] = None,
126
+ tag_groups: Optional[object] = None,
103
127
  timeout: int = 10,
104
128
  ) -> dict:
105
129
  """Recall memories from a bank.
@@ -115,6 +139,12 @@ class HindsightClient:
115
139
  body["budget"] = budget
116
140
  if types:
117
141
  body["types"] = types
142
+ if tags:
143
+ body["tags"] = tags
144
+ if tags_match:
145
+ body["tags_match"] = tags_match
146
+ if tag_groups:
147
+ body["tag_groups"] = tag_groups
118
148
  return self._request("POST", path, body, timeout=timeout)
119
149
 
120
150
  def retain(
@@ -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
@@ -167,13 +167,48 @@ def truncate_recall_query(query: str, latest_query: str, max_chars: int) -> str:
167
167
  # ---------------------------------------------------------------------------
168
168
 
169
169
 
170
+ def _is_tool_result_only_user_message(message: dict) -> bool:
171
+ """True when a ``role="user"`` message carries ONLY tool_result blocks.
172
+
173
+ SWITCHROOM DIVERGENCE (candidate to upstream to vectorize-io/hindsight):
174
+ Claude Code emits tool results as ``role="user"`` messages whose content
175
+ is a list of ``{"type": "tool_result", ...}`` blocks — they are NOT
176
+ human turns. A genuine human turn has text (a string, or a content list
177
+ with at least one non-tool_result block, e.g. ``{"type": "text"}`` or an
178
+ image). Treating tool_result messages as turn boundaries lets a tool-heavy
179
+ turn (≥N sequential tool rounds) fill a fixed-size retain window with
180
+ tool_result messages and push the actual human message OUTSIDE the window
181
+ — silently dropping the fact from that fire, and from every later fire
182
+ (whose window starts even further from the human message). On restart the
183
+ fact is gone. This helper lets the boundary counter skip those messages so
184
+ "window = N turns" means N *human* turns regardless of tool volume.
185
+ """
186
+ if message.get("role") != "user":
187
+ return False
188
+ content = message.get("content")
189
+ if isinstance(content, list):
190
+ blocks = [b for b in content if isinstance(b, dict)]
191
+ # A non-empty content list that is ENTIRELY tool_result blocks.
192
+ if blocks and all(b.get("type") == "tool_result" for b in blocks):
193
+ return True
194
+ return False
195
+
196
+
170
197
  def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
171
198
  """Slice messages to the last N turns, where a turn starts at a user message.
172
199
 
173
200
  Port of: sliceLastTurnsByUserBoundary() in index.js
174
201
 
175
- Walks backward counting user messages as turn boundaries. Returns
176
- messages from the Nth user boundary to the end.
202
+ Walks backward counting GENUINE HUMAN user messages as turn boundaries.
203
+ Returns messages from the Nth human boundary to the end.
204
+
205
+ SWITCHROOM DIVERGENCE (candidate to upstream): tool_result messages carry
206
+ ``role="user"`` in the Claude Code transcript but are not human turns; they
207
+ are skipped as boundaries (see ``_is_tool_result_only_user_message``). This
208
+ keeps the fixed-size retain window anchored to human turns so a tool-heavy
209
+ turn can never push the human's fact outside the window (silent memory loss).
210
+ Affects both the retain window-slice and the recall context slice — both
211
+ want "N human turns", not "N transcript user-messages".
177
212
  """
178
213
  if not isinstance(messages, list) or not messages or turns <= 0:
179
214
  return []
@@ -182,7 +217,8 @@ def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
182
217
  start_index = -1
183
218
 
184
219
  for i in range(len(messages) - 1, -1, -1):
185
- if messages[i].get("role") == "user":
220
+ msg = messages[i]
221
+ if msg.get("role") == "user" and not _is_tool_result_only_user_message(msg):
186
222
  user_turns_seen += 1
187
223
  if user_turns_seen >= turns:
188
224
  start_index = i
@@ -221,10 +257,13 @@ def format_memories(results: list) -> str:
221
257
  def format_current_time() -> str:
222
258
  """Format current UTC time for recall context.
223
259
 
260
+ The "UTC" suffix is explicit so client LLMs do not misread the
261
+ value as local time when reasoning about wall-clock context.
262
+
224
263
  Port of: formatCurrentTimeForRecall() in index.js
225
264
  """
226
265
  now = datetime.now(timezone.utc)
227
- return now.strftime("%Y-%m-%d %H:%M")
266
+ return now.strftime("%Y-%m-%d %H:%M UTC")
228
267
 
229
268
 
230
269
  # ---------------------------------------------------------------------------
@@ -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
@@ -69,6 +69,60 @@ def read_transcript(transcript_path: str) -> list:
69
69
  return messages
70
70
 
71
71
 
72
+ def select_retain_window(
73
+ retain_mode: str,
74
+ retain_every_n: int,
75
+ overlap_turns: int,
76
+ all_messages: list,
77
+ force: bool = False,
78
+ ) -> tuple:
79
+ """Decide which messages to retain and whether to send as a full window.
80
+
81
+ Returns ``(messages_to_retain, retain_full_window)``.
82
+
83
+ SWITCHROOM DIVERGENCE (Phase 6b — candidate to upstream to
84
+ vectorize-io/hindsight): the chunked sliding-window is decoupled from
85
+ the ``retainEveryNTurns > 1`` throttle. Upstream only sliced a window
86
+ when ``retain_every_n > 1``; with ``retainEveryNTurns=1`` (switchroom's
87
+ every-turn crash-durability setting, applied in scaffold.ts) chunked
88
+ mode fell through to full-session and re-consolidated the ENTIRE
89
+ accumulated transcript on every Stop fire — an unbounded, per-turn cost.
90
+
91
+ Decoupling is safe because window selection and the throttle answer two
92
+ independent questions: the throttle decides *whether* to fire this turn
93
+ (still owned by run_retain, unchanged); this function only decides *what*
94
+ to retain once a fire happens. A chunked window of
95
+ ``max(retain_every_n, 1) + overlap_turns`` turns is correct for any
96
+ ``retain_every_n >= 1``. With ``retain_every_n=1, overlap=2`` the window
97
+ is the 3 most-recent HUMAN turns (tool_result messages don't count as
98
+ turns — see slice_last_turns_by_user_boundary).
99
+
100
+ ``force=True`` (SessionEnd final retain) widens chunked mode to a
101
+ full-session sweep — belt-and-braces so a graceful shutdown always flushes
102
+ the whole session even if per-turn windowing had an edge. This costs a
103
+ full sweep only ONCE per session (at end), not per turn.
104
+
105
+ Durability invariant (jtbd-memory-survives-restart UAT): the window
106
+ always extends to the END of the transcript (``slice_last_turns_by_user_boundary``
107
+ returns ``messages[start:]``), so the turn that just completed — the one
108
+ whose Stop hook is firing — is ALWAYS included. Every turn fires (no
109
+ throttle at n=1), so every turn's content is retained on its own fire.
110
+ Boundaries are counted on human messages only, so a tool-heavy turn can't
111
+ push the human's fact outside the window. No fact can fall outside every
112
+ window.
113
+ """
114
+ if retain_mode == "chunked" and not force:
115
+ # Sliding window: N turns + configured overlap. max(retain_every_n, 1)
116
+ # keeps the window valid at n=1 (the decoupling); for n>1 this equals
117
+ # the previous `retain_every_n + overlap_turns` (behaviour unchanged).
118
+ window_turns = max(retain_every_n, 1) + overlap_turns
119
+ messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
120
+ return messages_to_retain, True
121
+ # Full session: vendor full-session mode, OR a forced (SessionEnd) chunked
122
+ # sweep. Retain all messages, always as a full window.
123
+ return list(all_messages), True
124
+
125
+
72
126
  def run_retain(hook_input: dict, force: bool = False) -> dict:
73
127
  """Run the auto-retain flow.
74
128
 
@@ -104,7 +158,8 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
104
158
 
105
159
  debug_log(config, f"Read {len(all_messages)} messages from transcript")
106
160
 
107
- # Retention mode: full session (default) or chunked (legacy)
161
+ # Retention mode: full session (vendor default) or chunked. Switchroom
162
+ # runs chunked at retainEveryNTurns=1 (see select_retain_window / scaffold.ts).
108
163
  retain_mode = config.get("retainMode", "full-session")
109
164
  retain_every_n = max(1, config.get("retainEveryNTurns", 1))
110
165
  retain_full_window = False
@@ -118,19 +173,25 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
118
173
  debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
119
174
  return {"status": "skipped", "reason": "throttled"}
120
175
 
121
- if retain_mode == "chunked" and retain_every_n > 1:
122
- # Sliding window: N turns + configured overlap
123
- overlap_turns = config.get("retainOverlapTurns", 0)
124
- window_turns = retain_every_n + overlap_turns
125
- messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
126
- retain_full_window = True
176
+ # Window selection is decoupled from the throttle above — see
177
+ # select_retain_window() for the switchroom-divergence rationale
178
+ # (Phase 6b: chunked window-slicing now works at retainEveryNTurns=1).
179
+ overlap_turns = config.get("retainOverlapTurns", 0)
180
+ messages_to_retain, retain_full_window = select_retain_window(
181
+ retain_mode, retain_every_n, overlap_turns, all_messages, force=force
182
+ )
183
+ if retain_mode == "chunked" and not force:
184
+ window_turns = max(retain_every_n, 1) + overlap_turns
127
185
  debug_log(
128
186
  config,
129
- f"Chunked retain firing (window: {window_turns} turns, {len(messages_to_retain)} messages)",
187
+ f"Chunked retain firing (window: {window_turns} human turns, {len(messages_to_retain)} messages)",
188
+ )
189
+ elif retain_mode == "chunked" and force:
190
+ debug_log(
191
+ config,
192
+ f"Chunked retain, forced full-session sweep (SessionEnd): {len(all_messages)} messages",
130
193
  )
131
194
  else:
132
- # Full session mode: retain all messages, always as full window
133
- retain_full_window = True
134
195
  debug_log(config, f"Full session retain: {len(all_messages)} messages")
135
196
 
136
197
  # Format transcript
@@ -156,7 +217,14 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
156
217
 
157
218
  api_token = config.get("hindsightApiToken")
158
219
  try:
159
- client = HindsightClient(api_url, api_token)
220
+ # Upstream 55ef70679 — honor the optional requestTimeoutSeconds
221
+ # override (retain runs outside the recall hook budget, so a longer
222
+ # timeout is safe here; recall.py deliberately omits this).
223
+ client = HindsightClient(
224
+ api_url,
225
+ api_token,
226
+ request_timeout_override=config.get("requestTimeoutSeconds"),
227
+ )
160
228
  except ValueError as e:
161
229
  print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
162
230
  return {"status": "failed", "error": e, "payload": None}